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:
parent
9139d5a93a
commit
9431aa2095
13 changed files with 1084 additions and 4 deletions
|
|
@ -207,7 +207,7 @@ Build the creator-facing post editor with Tiptap rich text editing and file atta
|
||||||
- Estimate: 1h30m
|
- 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
|
- 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
|
- 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.
|
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.
|
||||||
|
|
||||||
|
|
|
||||||
16
.gsd/milestones/M023/slices/S01/tasks/T03-VERIFY.json
Normal file
16
.gsd/milestones/M023/slices/S01/tasks/T03-VERIFY.json
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
87
.gsd/milestones/M023/slices/S01/tasks/T04-SUMMARY.md
Normal file
87
.gsd/milestones/M023/slices/S01/tasks/T04-SUMMARY.md
Normal 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.
|
||||||
113
frontend/package-lock.json
generated
113
frontend/package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/extension-link": "^3.22.1",
|
"@tiptap/extension-link": "^3.22.1",
|
||||||
"@tiptap/extension-placeholder": "^3.22.1",
|
"@tiptap/extension-placeholder": "^3.22.1",
|
||||||
|
"@tiptap/html": "^3.22.1",
|
||||||
"@tiptap/pm": "^3.22.1",
|
"@tiptap/pm": "^3.22.1",
|
||||||
"@tiptap/react": "^3.22.1",
|
"@tiptap/react": "^3.22.1",
|
||||||
"@tiptap/starter-kit": "^3.22.1",
|
"@tiptap/starter-kit": "^3.22.1",
|
||||||
|
|
@ -1555,6 +1556,21 @@
|
||||||
"@tiptap/pm": "^3.22.1"
|
"@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": {
|
"node_modules/@tiptap/pm": {
|
||||||
"version": "3.22.1",
|
"version": "3.22.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.1.tgz",
|
||||||
|
|
@ -1722,6 +1738,16 @@
|
||||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.15",
|
"version": "15.7.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
"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==",
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||||
|
|
@ -2020,6 +2063,37 @@
|
||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/hls.js": {
|
||||||
"version": "1.6.15",
|
"version": "1.6.15",
|
||||||
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
|
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.15.tgz",
|
||||||
|
|
@ -2594,6 +2668,13 @@
|
||||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
|
|
@ -2721,6 +2802,38 @@
|
||||||
"integrity": "sha512-MSZcA13R9ZlxgYpzfakaSYf8dz5tCdZKYbjtN1qnKbCi+UoyfaTuhvjlXHrITi/fgeO3qWfsH7U3BP1AKnwRNg==",
|
"integrity": "sha512-MSZcA13R9ZlxgYpzfakaSYf8dz5tCdZKYbjtN1qnKbCi+UoyfaTuhvjlXHrITi/fgeO3qWfsH7U3BP1AKnwRNg==",
|
||||||
"license": "BSD-3-Clause"
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/extension-link": "^3.22.1",
|
"@tiptap/extension-link": "^3.22.1",
|
||||||
"@tiptap/extension-placeholder": "^3.22.1",
|
"@tiptap/extension-placeholder": "^3.22.1",
|
||||||
|
"@tiptap/html": "^3.22.1",
|
||||||
"@tiptap/pm": "^3.22.1",
|
"@tiptap/pm": "^3.22.1",
|
||||||
"@tiptap/react": "^3.22.1",
|
"@tiptap/react": "^3.22.1",
|
||||||
"@tiptap/starter-kit": "^3.22.1",
|
"@tiptap/starter-kit": "^3.22.1",
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ const ChapterReview = React.lazy(() => import("./pages/ChapterReview"));
|
||||||
const HighlightQueue = React.lazy(() => import("./pages/HighlightQueue"));
|
const HighlightQueue = React.lazy(() => import("./pages/HighlightQueue"));
|
||||||
const CreatorTiers = React.lazy(() => import("./pages/CreatorTiers"));
|
const CreatorTiers = React.lazy(() => import("./pages/CreatorTiers"));
|
||||||
const PostEditor = React.lazy(() => import("./pages/PostEditor"));
|
const PostEditor = React.lazy(() => import("./pages/PostEditor"));
|
||||||
|
const PostsList = React.lazy(() => import("./pages/PostsList"));
|
||||||
import AdminDropdown from "./components/AdminDropdown";
|
import AdminDropdown from "./components/AdminDropdown";
|
||||||
import ImpersonationBanner from "./components/ImpersonationBanner";
|
import ImpersonationBanner from "./components/ImpersonationBanner";
|
||||||
import AppFooter from "./components/AppFooter";
|
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/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/highlights" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><HighlightQueue /></Suspense></ProtectedRoute>} />
|
||||||
<Route path="/creator/tiers" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><CreatorTiers /></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/new" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><PostEditor /></Suspense></ProtectedRoute>} />
|
||||||
<Route path="/creator/posts/:postId/edit" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><PostEditor /></Suspense></ProtectedRoute>} />
|
<Route path="/creator/posts/:postId/edit" element={<ProtectedRoute><Suspense fallback={<LoadingFallback />}><PostEditor /></Suspense></ProtectedRoute>} />
|
||||||
|
|
||||||
|
|
|
||||||
201
frontend/src/components/PostsFeed.module.css
Normal file
201
frontend/src/components/PostsFeed.module.css
Normal 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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
165
frontend/src/components/PostsFeed.tsx
Normal file
165
frontend/src/components/PostsFeed.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -59,12 +59,12 @@ function SidebarNav() {
|
||||||
</svg>
|
</svg>
|
||||||
Tiers
|
Tiers
|
||||||
</NavLink>
|
</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">
|
<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="M12 20h9" />
|
||||||
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
New Post
|
Posts
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { useAuth } from "../context/AuthContext";
|
||||||
import CreatorAvatar from "../components/CreatorAvatar";
|
import CreatorAvatar from "../components/CreatorAvatar";
|
||||||
import { SocialIcon } from "../components/SocialIcons";
|
import { SocialIcon } from "../components/SocialIcons";
|
||||||
import ChatWidget from "../components/ChatWidget";
|
import ChatWidget from "../components/ChatWidget";
|
||||||
|
import PostsFeed from "../components/PostsFeed";
|
||||||
import PersonalityProfile from "../components/PersonalityProfile";
|
import PersonalityProfile from "../components/PersonalityProfile";
|
||||||
import SortDropdown from "../components/SortDropdown";
|
import SortDropdown from "../components/SortDropdown";
|
||||||
import TagList from "../components/TagList";
|
import TagList from "../components/TagList";
|
||||||
|
|
@ -484,6 +485,9 @@ export default function CreatorDetail() {
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Posts feed */}
|
||||||
|
<PostsFeed creatorId={creator.id} />
|
||||||
|
|
||||||
<ChatWidget creatorName={creator.name} techniques={creator.techniques} />
|
<ChatWidget creatorName={creator.name} techniques={creator.techniques} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
300
frontend/src/pages/PostsList.module.css
Normal file
300
frontend/src/pages/PostsList.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
191
frontend/src/pages/PostsList.tsx
Normal file
191
frontend/src/pages/PostsList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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"}
|
||||||
Loading…
Add table
Reference in a new issue