diff --git a/.gsd/milestones/M023/slices/S01/S01-PLAN.md b/.gsd/milestones/M023/slices/S01/S01-PLAN.md index a8ae806..a973b9a 100644 --- a/.gsd/milestones/M023/slices/S01/S01-PLAN.md +++ b/.gsd/milestones/M023/slices/S01/S01-PLAN.md @@ -207,7 +207,7 @@ Build the creator-facing post editor with Tiptap rich text editing and file atta - Estimate: 1h30m - Files: frontend/package.json, frontend/src/api/client.ts, frontend/src/api/posts.ts, frontend/src/pages/PostEditor.tsx, frontend/src/pages/PostEditor.module.css, frontend/src/App.tsx, frontend/src/pages/CreatorDashboard.tsx - Verify: cd frontend && npm run build 2>&1 | tail -5 -- [ ] **T04: Posts feed on creator profile with file download buttons** — ## Description +- [x] **T04: Added PostsFeed component to creator profile pages with Tiptap HTML rendering and signed-URL file downloads, plus PostsList management page for creators** — ## Description Build the public-facing read path: a PostsFeed component on the creator detail page showing published posts reverse-chronologically, with file download buttons using signed URLs. Also add a posts list page at `/creator/posts` for the creator to manage their posts. diff --git a/.gsd/milestones/M023/slices/S01/tasks/T03-VERIFY.json b/.gsd/milestones/M023/slices/S01/tasks/T03-VERIFY.json new file mode 100644 index 0000000..43f2ef1 --- /dev/null +++ b/.gsd/milestones/M023/slices/S01/tasks/T03-VERIFY.json @@ -0,0 +1,16 @@ +{ + "schemaVersion": 1, + "taskId": "T03", + "unitId": "M023/S01/T03", + "timestamp": 1775294028849, + "passed": true, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd frontend", + "exitCode": 0, + "durationMs": 8, + "verdict": "pass" + } + ] +} diff --git a/.gsd/milestones/M023/slices/S01/tasks/T04-SUMMARY.md b/.gsd/milestones/M023/slices/S01/tasks/T04-SUMMARY.md new file mode 100644 index 0000000..31add35 --- /dev/null +++ b/.gsd/milestones/M023/slices/S01/tasks/T04-SUMMARY.md @@ -0,0 +1,87 @@ +--- +id: T04 +parent: S01 +milestone: M023 +provides: [] +requires: [] +affects: [] +key_files: ["frontend/src/components/PostsFeed.tsx", "frontend/src/components/PostsFeed.module.css", "frontend/src/pages/PostsList.tsx", "frontend/src/pages/PostsList.module.css", "frontend/src/pages/CreatorDetail.tsx", "frontend/src/App.tsx", "frontend/src/pages/CreatorDashboard.tsx"] +key_decisions: ["PostsFeed returns null when total === 0 — hides section on public profile", "SidebarNav changed from New Post to Posts management link"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "Frontend build succeeds — PostsFeed and PostsList compile and chunk correctly. All route definitions valid." +completed_at: 2026-04-04T09:17:22.826Z +blocker_discovered: false +--- + +# T04: Added PostsFeed component to creator profile pages with Tiptap HTML rendering and signed-URL file downloads, plus PostsList management page for creators + +> Added PostsFeed component to creator profile pages with Tiptap HTML rendering and signed-URL file downloads, plus PostsList management page for creators + +## What Happened +--- +id: T04 +parent: S01 +milestone: M023 +key_files: + - frontend/src/components/PostsFeed.tsx + - frontend/src/components/PostsFeed.module.css + - frontend/src/pages/PostsList.tsx + - frontend/src/pages/PostsList.module.css + - frontend/src/pages/CreatorDetail.tsx + - frontend/src/App.tsx + - frontend/src/pages/CreatorDashboard.tsx +key_decisions: + - PostsFeed returns null when total === 0 — hides section on public profile + - SidebarNav changed from New Post to Posts management link +duration: "" +verification_result: passed +completed_at: 2026-04-04T09:17:22.827Z +blocker_discovered: false +--- + +# T04: Added PostsFeed component to creator profile pages with Tiptap HTML rendering and signed-URL file downloads, plus PostsList management page for creators + +**Added PostsFeed component to creator profile pages with Tiptap HTML rendering and signed-URL file downloads, plus PostsList management page for creators** + +## What Happened + +Created PostsFeed component that renders published posts with Tiptap JSON→HTML conversion and file attachment download buttons using signed URLs. Integrated into CreatorDetail page. Created PostsList management page at /creator/posts with table/card layouts, status badges, edit/delete actions with confirmation dialog. Updated SidebarNav and App.tsx routes. + +## Verification + +Frontend build succeeds — PostsFeed and PostsList compile and chunk correctly. All route definitions valid. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd frontend && npm run build 2>&1 | tail -5` | 0 | ✅ pass | 6700ms | + + +## Deviations + +Changed SidebarNav 'New Post' direct link to 'Posts' management page link — more useful with the management page now available. + +## Known Issues + +None. + +## Files Created/Modified + +- `frontend/src/components/PostsFeed.tsx` +- `frontend/src/components/PostsFeed.module.css` +- `frontend/src/pages/PostsList.tsx` +- `frontend/src/pages/PostsList.module.css` +- `frontend/src/pages/CreatorDetail.tsx` +- `frontend/src/App.tsx` +- `frontend/src/pages/CreatorDashboard.tsx` + + +## Deviations +Changed SidebarNav 'New Post' direct link to 'Posts' management page link — more useful with the management page now available. + +## Known Issues +None. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 50be9af..e0e5c71 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tiptap/extension-link": "^3.22.1", "@tiptap/extension-placeholder": "^3.22.1", + "@tiptap/html": "^3.22.1", "@tiptap/pm": "^3.22.1", "@tiptap/react": "^3.22.1", "@tiptap/starter-kit": "^3.22.1", @@ -1555,6 +1556,21 @@ "@tiptap/pm": "^3.22.1" } }, + "node_modules/@tiptap/html": { + "version": "3.22.1", + "resolved": "https://registry.npmjs.org/@tiptap/html/-/html-3.22.1.tgz", + "integrity": "sha512-EC+BSHDRnHOWlu8aqImE8PtPO6S/swHQYFA+dLpmgyS6fVtSz/VF7jZLRPjVvAX5KXKeXfBvveIbQEAYqhwVnA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^3.22.1", + "@tiptap/pm": "^3.22.1", + "happy-dom": "^20.8.9" + } + }, "node_modules/@tiptap/pm": { "version": "3.22.1", "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.1.tgz", @@ -1722,6 +1738,16 @@ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -1753,6 +1779,23 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "license": "MIT", + "peer": true + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2020,6 +2063,37 @@ "node": ">=6.9.0" } }, + "node_modules/happy-dom": { + "version": "20.8.9", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz", + "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/hls.js": { "version": "1.6.15", "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz", @@ -2594,6 +2668,13 @@ "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", "license": "MIT" }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT", + "peer": true + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -2721,6 +2802,38 @@ "integrity": "sha512-MSZcA13R9ZlxgYpzfakaSYf8dz5tCdZKYbjtN1qnKbCi+UoyfaTuhvjlXHrITi/fgeO3qWfsH7U3BP1AKnwRNg==", "license": "BSD-3-Clause" }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 17c248b..cfcb97f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,6 +11,7 @@ "dependencies": { "@tiptap/extension-link": "^3.22.1", "@tiptap/extension-placeholder": "^3.22.1", + "@tiptap/html": "^3.22.1", "@tiptap/pm": "^3.22.1", "@tiptap/react": "^3.22.1", "@tiptap/starter-kit": "^3.22.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bb840bf..5c8b5be 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,7 @@ const ChapterReview = React.lazy(() => import("./pages/ChapterReview")); const HighlightQueue = React.lazy(() => import("./pages/HighlightQueue")); const CreatorTiers = React.lazy(() => import("./pages/CreatorTiers")); const PostEditor = React.lazy(() => import("./pages/PostEditor")); +const PostsList = React.lazy(() => import("./pages/PostsList")); import AdminDropdown from "./components/AdminDropdown"; import ImpersonationBanner from "./components/ImpersonationBanner"; import AppFooter from "./components/AppFooter"; @@ -208,6 +209,7 @@ function AppShell() { }>} /> }>} /> }>} /> + }>} /> }>} /> }>} /> diff --git a/frontend/src/components/PostsFeed.module.css b/frontend/src/components/PostsFeed.module.css new file mode 100644 index 0000000..c902a14 --- /dev/null +++ b/frontend/src/components/PostsFeed.module.css @@ -0,0 +1,201 @@ +/* PostsFeed.module.css — dark theme post cards for creator profile */ + +.section { + margin-top: 2.5rem; +} + +.sectionHeader { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin-bottom: 1.25rem; +} + +.sectionTitle { + font-size: 1.25rem; + font-weight: 700; + color: var(--color-text-primary); + margin: 0; +} + +.postCount { + font-size: 0.875rem; + color: var(--color-text-secondary); + font-weight: 400; +} + +/* ── Feed list ─────────────────────────────────────────────────────────────── */ + +.feed { + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +/* ── Post card ─────────────────────────────────────────────────────────────── */ + +.card { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 12px; + padding: 1.5rem; + transition: border-color 0.15s; +} + +.card:hover { + border-color: var(--color-border-active, var(--color-border)); +} + +.cardTitle { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text-primary); + margin: 0 0 0.5rem; +} + +.cardMeta { + font-size: 0.8rem; + color: var(--color-text-muted, var(--color-text-secondary)); + margin-bottom: 1rem; +} + +/* ── Rich text body ────────────────────────────────────────────────────────── */ + +.cardBody { + font-size: 0.925rem; + line-height: 1.7; + color: var(--color-text-secondary); + margin-bottom: 1rem; + overflow: hidden; +} + +.cardBody h1, +.cardBody h2, +.cardBody h3 { + color: var(--color-text-primary); + margin-top: 1rem; + margin-bottom: 0.5rem; +} + +.cardBody p { + margin: 0 0 0.75rem; +} + +.cardBody ul, +.cardBody ol { + padding-left: 1.5rem; + margin: 0 0 0.75rem; +} + +.cardBody blockquote { + border-left: 3px solid var(--color-accent, #22d3ee); + padding-left: 1rem; + margin: 0 0 0.75rem; + color: var(--color-text-muted, var(--color-text-secondary)); + font-style: italic; +} + +.cardBody a { + color: var(--color-accent, #22d3ee); + text-decoration: underline; +} + +.cardBody code { + background: var(--color-bg-input, #1e293b); + padding: 0.15em 0.35em; + border-radius: 4px; + font-size: 0.875em; +} + +.cardBody pre { + background: var(--color-bg-input, #1e293b); + padding: 0.75rem 1rem; + border-radius: 8px; + overflow-x: auto; + margin: 0 0 0.75rem; +} + +/* ── Attachments ───────────────────────────────────────────────────────────── */ + +.attachments { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.attachment { + display: inline-flex; + align-items: center; + gap: 0.5rem; + background: var(--color-bg-input, #1e293b); + border: 1px solid var(--color-border); + border-radius: 8px; + padding: 0.5rem 0.75rem; + font-size: 0.825rem; + color: var(--color-text-secondary); + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + text-decoration: none; +} + +.attachment:hover { + background: var(--color-bg-surface-hover, #334155); + border-color: var(--color-accent, #22d3ee); +} + +.attachmentIcon { + width: 16px; + height: 16px; + flex-shrink: 0; + color: var(--color-accent, #22d3ee); +} + +.attachmentName { + font-weight: 500; + color: var(--color-text-primary); + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.attachmentSize { + color: var(--color-text-muted, var(--color-text-secondary)); + font-size: 0.75rem; +} + +/* ── States ────────────────────────────────────────────────────────────────── */ + +.loading { + color: var(--color-text-secondary); + font-size: 0.95rem; + padding: 1.5rem 0; +} + +.empty { + color: var(--color-text-muted, var(--color-text-secondary)); + font-size: 0.925rem; + padding: 1.5rem 0; +} + +.error { + color: var(--color-error, #ef4444); + font-size: 0.925rem; + padding: 1.5rem 0; +} + +/* ── Responsive ────────────────────────────────────────────────────────────── */ + +@media (max-width: 640px) { + .card { + padding: 1rem; + } + + .attachments { + flex-direction: column; + } + + .attachment { + width: 100%; + } +} diff --git a/frontend/src/components/PostsFeed.tsx b/frontend/src/components/PostsFeed.tsx new file mode 100644 index 0000000..d754a1e --- /dev/null +++ b/frontend/src/components/PostsFeed.tsx @@ -0,0 +1,165 @@ +/** + * PostsFeed — Renders a reverse-chronological feed of published posts + * for a given creator. Used on the public creator profile page. + */ + +import { useCallback, useEffect, useState } from "react"; +import { generateHTML } from "@tiptap/html"; +import StarterKit from "@tiptap/starter-kit"; +import Link from "@tiptap/extension-link"; +import { + listPosts, + getDownloadUrl, + type PostRead, +} from "../api/posts"; +import styles from "./PostsFeed.module.css"; + +// Tiptap extensions matching those in PostEditor — needed for generateHTML +const extensions = [StarterKit, Link]; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function FileIcon() { + return ( + + + + + + ); +} + +function AttachmentButton({ attachment }: { attachment: PostRead["attachments"][number] }) { + const [downloading, setDownloading] = useState(false); + + const handleDownload = useCallback(async () => { + if (downloading) return; + setDownloading(true); + try { + const { url } = await getDownloadUrl(String(attachment.id)); + window.open(url, "_blank", "noopener"); + } catch (err) { + console.error("Download failed:", err); + } finally { + setDownloading(false); + } + }, [attachment.id, downloading]); + + return ( + + ); +} + +function renderBody(bodyJson: Record): string { + try { + return generateHTML(bodyJson as Parameters[0], extensions); + } catch { + // Fallback: if body_json isn't valid Tiptap JSON, try plain text + return "

(Content unavailable)

"; + } +} + +interface PostsFeedProps { + creatorId: number | string; +} + +export default function PostsFeed({ creatorId }: PostsFeedProps) { + const [posts, setPosts] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + + listPosts(String(creatorId), 1, 50) + .then((res) => { + if (!cancelled) { + setPosts(res.items); + setTotal(res.total); + } + }) + .catch((err) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Failed to load posts"); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { cancelled = true; }; + }, [creatorId]); + + if (loading) { + return ( +
+
+

Posts

+
+
Loading posts…
+
+ ); + } + + if (error) { + return ( +
+
+

Posts

+
+
{error}
+
+ ); + } + + if (total === 0) { + return null; // Don't show empty posts section on public profile + } + + return ( +
+
+

Posts

+ ({total}) +
+
+ {posts.map((post) => ( +
+

{post.title}

+
{formatDate(post.created_at)}
+
+ {post.attachments.length > 0 && ( +
+ {post.attachments.map((att) => ( + + ))} +
+ )} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/pages/CreatorDashboard.tsx b/frontend/src/pages/CreatorDashboard.tsx index f04af87..a041f6b 100644 --- a/frontend/src/pages/CreatorDashboard.tsx +++ b/frontend/src/pages/CreatorDashboard.tsx @@ -59,12 +59,12 @@ function SidebarNav() { Tiers - + - New Post + Posts ); diff --git a/frontend/src/pages/CreatorDetail.tsx b/frontend/src/pages/CreatorDetail.tsx index c8f4ee7..aaf550c 100644 --- a/frontend/src/pages/CreatorDetail.tsx +++ b/frontend/src/pages/CreatorDetail.tsx @@ -19,6 +19,7 @@ import { useAuth } from "../context/AuthContext"; import CreatorAvatar from "../components/CreatorAvatar"; import { SocialIcon } from "../components/SocialIcons"; import ChatWidget from "../components/ChatWidget"; +import PostsFeed from "../components/PostsFeed"; import PersonalityProfile from "../components/PersonalityProfile"; import SortDropdown from "../components/SortDropdown"; import TagList from "../components/TagList"; @@ -484,6 +485,9 @@ export default function CreatorDetail() { )} + {/* Posts feed */} + + ); diff --git a/frontend/src/pages/PostsList.module.css b/frontend/src/pages/PostsList.module.css new file mode 100644 index 0000000..38216b7 --- /dev/null +++ b/frontend/src/pages/PostsList.module.css @@ -0,0 +1,300 @@ +/* PostsList.module.css — creator post management list */ + +.layout { + display: flex; + gap: 0; + min-height: 60vh; +} + +.content { + flex: 1; + min-width: 0; + padding: 2rem 2.5rem; +} + +/* ── Header ────────────────────────────────────────────────────────────────── */ + +.header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1.5rem; + gap: 1rem; +} + +.pageTitle { + font-size: 1.5rem; + font-weight: 700; + color: var(--color-text-primary); + margin: 0; +} + +.newBtn { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.5rem 1rem; + background: var(--color-accent, #22d3ee); + color: var(--color-bg, #0f172a); + border: none; + border-radius: 8px; + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + text-decoration: none; + transition: opacity 0.15s; +} + +.newBtn:hover { + opacity: 0.85; +} + +/* ── Table (desktop) ───────────────────────────────────────────────────────── */ + +.tableWrap { + overflow-x: auto; +} + +.table { + width: 100%; + border-collapse: collapse; +} + +.table th { + text-align: left; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--color-text-muted, var(--color-text-secondary)); + padding: 0.625rem 0.75rem; + border-bottom: 1px solid var(--color-border); +} + +.table td { + padding: 0.75rem; + font-size: 0.875rem; + color: var(--color-text-secondary); + border-bottom: 1px solid var(--color-border); + vertical-align: middle; +} + +.table tr:hover td { + background: var(--color-bg-surface-hover, rgba(255, 255, 255, 0.03)); +} + +.titleCell { + font-weight: 500; + color: var(--color-text-primary); +} + +/* ── Status badges ─────────────────────────────────────────────────────────── */ + +.badge { + display: inline-block; + padding: 0.2rem 0.6rem; + border-radius: 999px; + font-size: 0.725rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.badgePublished { + background: rgba(34, 197, 94, 0.15); + color: #4ade80; +} + +.badgeDraft { + background: rgba(251, 191, 36, 0.15); + color: #fbbf24; +} + +/* ── Actions ───────────────────────────────────────────────────────────────── */ + +.actions { + display: flex; + gap: 0.5rem; +} + +.editBtn, +.deleteBtn { + padding: 0.35rem 0.65rem; + border: 1px solid var(--color-border); + border-radius: 6px; + font-size: 0.8rem; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + text-decoration: none; + display: inline-flex; + align-items: center; +} + +.editBtn { + background: transparent; + color: var(--color-text-secondary); +} + +.editBtn:hover { + background: var(--color-bg-surface-hover, #334155); + border-color: var(--color-accent, #22d3ee); + color: var(--color-accent, #22d3ee); +} + +.deleteBtn { + background: transparent; + color: var(--color-text-secondary); +} + +.deleteBtn:hover { + background: rgba(239, 68, 68, 0.1); + border-color: #ef4444; + color: #ef4444; +} + +.deleteBtn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ── Mobile cards ──────────────────────────────────────────────────────────── */ + +.mobileCards { + display: none; +} + +/* ── States ────────────────────────────────────────────────────────────────── */ + +.loading { + color: var(--color-text-secondary); + font-size: 0.95rem; + padding: 2rem 0; +} + +.error { + color: var(--color-error, #ef4444); + font-size: 0.95rem; + padding: 2rem 0; +} + +.empty { + color: var(--color-text-muted, var(--color-text-secondary)); + font-size: 0.925rem; + padding: 2rem 0; + text-align: center; +} + +/* ── Confirm dialog ────────────────────────────────────────────────────────── */ + +.confirmOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.confirmDialog { + background: var(--color-bg-surface, #1e293b); + border: 1px solid var(--color-border); + border-radius: 12px; + padding: 1.5rem; + max-width: 400px; + width: 90%; +} + +.confirmTitle { + font-size: 1.1rem; + font-weight: 600; + color: var(--color-text-primary); + margin: 0 0 0.5rem; +} + +.confirmText { + font-size: 0.9rem; + color: var(--color-text-secondary); + margin: 0 0 1.25rem; +} + +.confirmActions { + display: flex; + gap: 0.75rem; + justify-content: flex-end; +} + +.confirmCancel { + padding: 0.45rem 1rem; + background: transparent; + border: 1px solid var(--color-border); + border-radius: 8px; + color: var(--color-text-secondary); + cursor: pointer; + font-size: 0.875rem; +} + +.confirmDelete { + padding: 0.45rem 1rem; + background: #ef4444; + border: none; + border-radius: 8px; + color: #fff; + cursor: pointer; + font-size: 0.875rem; + font-weight: 600; +} + +.confirmDelete:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* ── Responsive ────────────────────────────────────────────────────────────── */ + +@media (max-width: 768px) { + .content { + padding: 1.25rem; + } + + .tableWrap { + display: none; + } + + .mobileCards { + display: flex; + flex-direction: column; + gap: 0.75rem; + } + + .mobileCard { + background: var(--color-bg-surface); + border: 1px solid var(--color-border); + border-radius: 10px; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .mobileCardTitle { + font-weight: 500; + color: var(--color-text-primary); + font-size: 0.925rem; + } + + .mobileCardMeta { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; + color: var(--color-text-secondary); + flex-wrap: wrap; + } + + .mobileCardActions { + display: flex; + gap: 0.5rem; + margin-top: 0.25rem; + } +} diff --git a/frontend/src/pages/PostsList.tsx b/frontend/src/pages/PostsList.tsx new file mode 100644 index 0000000..f058387 --- /dev/null +++ b/frontend/src/pages/PostsList.tsx @@ -0,0 +1,191 @@ +/** + * PostsList — Creator post management page. + * + * Shows all posts (draft + published) with edit, delete, and status controls. + * Protected route at /creator/posts. + */ + +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; +import { useDocumentTitle } from "../hooks/useDocumentTitle"; +import { listPosts, deletePost, type PostRead } from "../api/posts"; +import { useAuth } from "../context/AuthContext"; +import { SidebarNav } from "./CreatorDashboard"; +import styles from "./PostsList.module.css"; + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +export default function PostsList() { + useDocumentTitle("My Posts — Chrysopedia"); + const { user } = useAuth(); + + const [posts, setPosts] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Delete confirmation + const [confirmDelete, setConfirmDelete] = useState(null); + const [deleting, setDeleting] = useState(false); + + useEffect(() => { + if (!user?.creator_id) return; + let cancelled = false; + setLoading(true); + setError(null); + + listPosts(String(user.creator_id), 1, 100) + .then((res) => { + if (!cancelled) setPosts(res.items); + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load posts"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { cancelled = true; }; + }, [user?.creator_id]); + + async function handleDelete() { + if (!confirmDelete) return; + setDeleting(true); + try { + await deletePost(String(confirmDelete.id)); + setPosts((prev) => prev.filter((p) => p.id !== confirmDelete.id)); + setConfirmDelete(null); + } catch (err) { + console.error("Delete failed:", err); + } finally { + setDeleting(false); + } + } + + return ( +
+ +
+
+

My Posts

+ + + New Post + +
+ + {loading &&
Loading posts…
} + + {!loading && error &&
{error}
} + + {!loading && !error && posts.length === 0 && ( +
+

No posts yet. Create your first post to share with followers.

+
+ )} + + {!loading && !error && posts.length > 0 && ( + <> + {/* Desktop table */} +
+ + + + + + + + + + + {posts.map((post) => ( + + + + + + + ))} + +
TitleStatusCreatedActions
{post.title} + + {post.is_published ? "Published" : "Draft"} + + {formatDate(post.created_at)} +
+ + Edit + + +
+
+
+ + {/* Mobile cards */} +
+ {posts.map((post) => ( +
+ {post.title} +
+ + {post.is_published ? "Published" : "Draft"} + + {formatDate(post.created_at)} +
+
+ + Edit + + +
+
+ ))} +
+ + )} + + {/* Delete confirmation dialog */} + {confirmDelete && ( +
!deleting && setConfirmDelete(null)}> +
e.stopPropagation()}> +

Delete Post

+

+ Are you sure you want to delete "{confirmDelete.title}"? This cannot be undone. +

+
+ + +
+
+
+ )} +
+
+ ); +} diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index f55eed7..d8bbd74 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/follows.ts","./src/api/highlights.ts","./src/api/index.ts","./src/api/posts.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ChatWidget.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PersonalityProfile.tsx","./src/components/PlayerControls.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorTiers.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/HighlightQueue.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/PostEditor.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/follows.ts","./src/api/highlights.ts","./src/api/index.ts","./src/api/posts.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ChatWidget.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PersonalityProfile.tsx","./src/components/PlayerControls.tsx","./src/components/PostsFeed.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorTiers.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/HighlightQueue.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/PostEditor.tsx","./src/pages/PostsList.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"} \ No newline at end of file