feat: Added PostsFeed component to creator profile pages with Tiptap HT…

- "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"

GSD-Task: S01/T04
This commit is contained in:
jlightner 2026-04-04 09:17:30 +00:00
parent 9139d5a93a
commit 9431aa2095
13 changed files with 1084 additions and 4 deletions

View file

@ -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.

View file

@ -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"
}
]
}

View file

@ -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.

View file

@ -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",

View file

@ -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",

View file

@ -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() {
<Route path="/creator/chapters/:videoId" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><ChapterReview /></Suspense></ProtectedRoute>} />
<Route path="/creator/highlights" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><HighlightQueue /></Suspense></ProtectedRoute>} />
<Route path="/creator/tiers" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><CreatorTiers /></Suspense></ProtectedRoute>} />
<Route path="/creator/posts" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><PostsList /></Suspense></ProtectedRoute>} />
<Route path="/creator/posts/new" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><PostEditor /></Suspense></ProtectedRoute>} />
<Route path="/creator/posts/:postId/edit" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><PostEditor /></Suspense></ProtectedRoute>} />

View file

@ -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%;
}
}

View file

@ -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 (
<svg className={styles.attachmentIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
);
}
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 (
<button className={styles.attachment} onClick={handleDownload} disabled={downloading}>
<FileIcon />
<span className={styles.attachmentName}>{attachment.filename}</span>
<span className={styles.attachmentSize}>{formatFileSize(attachment.size_bytes)}</span>
</button>
);
}
function renderBody(bodyJson: Record<string, unknown>): string {
try {
return generateHTML(bodyJson as Parameters<typeof generateHTML>[0], extensions);
} catch {
// Fallback: if body_json isn't valid Tiptap JSON, try plain text
return "<p>(Content unavailable)</p>";
}
}
interface PostsFeedProps {
creatorId: number | string;
}
export default function PostsFeed({ creatorId }: PostsFeedProps) {
const [posts, setPosts] = useState<PostRead[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<section className={styles.section}>
<div className={styles.sectionHeader}>
<h2 className={styles.sectionTitle}>Posts</h2>
</div>
<div className={styles.loading}>Loading posts</div>
</section>
);
}
if (error) {
return (
<section className={styles.section}>
<div className={styles.sectionHeader}>
<h2 className={styles.sectionTitle}>Posts</h2>
</div>
<div className={styles.error}>{error}</div>
</section>
);
}
if (total === 0) {
return null; // Don't show empty posts section on public profile
}
return (
<section className={styles.section}>
<div className={styles.sectionHeader}>
<h2 className={styles.sectionTitle}>Posts</h2>
<span className={styles.postCount}>({total})</span>
</div>
<div className={styles.feed}>
{posts.map((post) => (
<article key={post.id} className={styles.card}>
<h3 className={styles.cardTitle}>{post.title}</h3>
<div className={styles.cardMeta}>{formatDate(post.created_at)}</div>
<div
className={styles.cardBody}
dangerouslySetInnerHTML={{ __html: renderBody(post.body_json) }}
/>
{post.attachments.length > 0 && (
<div className={styles.attachments}>
{post.attachments.map((att) => (
<AttachmentButton key={att.id} attachment={att} />
))}
</div>
)}
</article>
))}
</div>
</section>
);
}

View file

@ -59,12 +59,12 @@ function SidebarNav() {
</svg>
Tiers
</NavLink>
<NavLink to="/creator/posts/new" className={linkClass}>
<NavLink to="/creator/posts" className={linkClass}>
<svg className={styles.sidebarIcon} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
New Post
Posts
</NavLink>
</nav>
);

View file

@ -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() {
)}
</section>
{/* Posts feed */}
<PostsFeed creatorId={creator.id} />
<ChatWidget creatorName={creator.name} techniques={creator.techniques} />
</div>
);

View file

@ -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;
}
}

View file

@ -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<PostRead[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Delete confirmation
const [confirmDelete, setConfirmDelete] = useState<PostRead | null>(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 (
<div className={styles.layout}>
<SidebarNav />
<div className={styles.content}>
<div className={styles.header}>
<h1 className={styles.pageTitle}>My Posts</h1>
<Link to="/creator/posts/new" className={styles.newBtn}>
+ New Post
</Link>
</div>
{loading && <div className={styles.loading}>Loading posts</div>}
{!loading && error && <div className={styles.error}>{error}</div>}
{!loading && !error && posts.length === 0 && (
<div className={styles.empty}>
<p>No posts yet. Create your first post to share with followers.</p>
</div>
)}
{!loading && !error && posts.length > 0 && (
<>
{/* Desktop table */}
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{posts.map((post) => (
<tr key={post.id}>
<td className={styles.titleCell}>{post.title}</td>
<td>
<span className={`${styles.badge} ${post.is_published ? styles.badgePublished : styles.badgeDraft}`}>
{post.is_published ? "Published" : "Draft"}
</span>
</td>
<td>{formatDate(post.created_at)}</td>
<td>
<div className={styles.actions}>
<Link to={`/creator/posts/${post.id}/edit`} className={styles.editBtn}>
Edit
</Link>
<button
className={styles.deleteBtn}
onClick={() => setConfirmDelete(post)}
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className={styles.mobileCards}>
{posts.map((post) => (
<div key={post.id} className={styles.mobileCard}>
<span className={styles.mobileCardTitle}>{post.title}</span>
<div className={styles.mobileCardMeta}>
<span className={`${styles.badge} ${post.is_published ? styles.badgePublished : styles.badgeDraft}`}>
{post.is_published ? "Published" : "Draft"}
</span>
<span>{formatDate(post.created_at)}</span>
</div>
<div className={styles.mobileCardActions}>
<Link to={`/creator/posts/${post.id}/edit`} className={styles.editBtn}>
Edit
</Link>
<button
className={styles.deleteBtn}
onClick={() => setConfirmDelete(post)}
>
Delete
</button>
</div>
</div>
))}
</div>
</>
)}
{/* Delete confirmation dialog */}
{confirmDelete && (
<div className={styles.confirmOverlay} onClick={() => !deleting && setConfirmDelete(null)}>
<div className={styles.confirmDialog} onClick={(e) => e.stopPropagation()}>
<h3 className={styles.confirmTitle}>Delete Post</h3>
<p className={styles.confirmText}>
Are you sure you want to delete "{confirmDelete.title}"? This cannot be undone.
</p>
<div className={styles.confirmActions}>
<button
className={styles.confirmCancel}
onClick={() => setConfirmDelete(null)}
disabled={deleting}
>
Cancel
</button>
<button
className={styles.confirmDelete}
onClick={handleDelete}
disabled={deleting}
>
{deleting ? "Deleting…" : "Delete"}
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View file

@ -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"}
{"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"}