create-fate 0.0.0 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +24 -0
- package/bin/create-fate.mjs +412 -0
- package/package.json +26 -3
- package/templates/fate/README.md +8 -0
- package/templates/fate/drizzle/.github/workflows/push.yml +27 -0
- package/templates/fate/drizzle/.oxfmtrc.jsonc +23 -0
- package/templates/fate/drizzle/.vite-hooks/pre-commit +1 -0
- package/templates/fate/drizzle/.vscode/extensions.json +12 -0
- package/templates/fate/drizzle/.vscode/settings.json +25 -0
- package/templates/fate/drizzle/AGENTS.md +55 -0
- package/templates/fate/drizzle/README.md +112 -0
- package/templates/fate/drizzle/_gitignore +20 -0
- package/templates/fate/drizzle/client/package.json +58 -0
- package/templates/fate/drizzle/client/pages/index.tsx +96 -0
- package/templates/fate/drizzle/client/pages/layout.tsx +104 -0
- package/templates/fate/drizzle/client/pages/login.tsx +107 -0
- package/templates/fate/drizzle/client/pages/post/[id].tsx +22 -0
- package/templates/fate/drizzle/client/pages/search.tsx +95 -0
- package/templates/fate/drizzle/client/public/icon.svg +1 -0
- package/templates/fate/drizzle/client/src/App.css +153 -0
- package/templates/fate/drizzle/client/src/lib/AvailableLanguages.tsx +5 -0
- package/templates/fate/drizzle/client/src/lib/cx.tsx +6 -0
- package/templates/fate/drizzle/client/src/lib/env.tsx +5 -0
- package/templates/fate/drizzle/client/src/ui/Badge.tsx +31 -0
- package/templates/fate/drizzle/client/src/ui/Button.tsx +108 -0
- package/templates/fate/drizzle/client/src/ui/Card.tsx +32 -0
- package/templates/fate/drizzle/client/src/ui/CommentCard.tsx +75 -0
- package/templates/fate/drizzle/client/src/ui/Error.tsx +19 -0
- package/templates/fate/drizzle/client/src/ui/H2.tsx +15 -0
- package/templates/fate/drizzle/client/src/ui/H3.tsx +20 -0
- package/templates/fate/drizzle/client/src/ui/Header.tsx +96 -0
- package/templates/fate/drizzle/client/src/ui/Input.tsx +17 -0
- package/templates/fate/drizzle/client/src/ui/Link.tsx +11 -0
- package/templates/fate/drizzle/client/src/ui/LocaleSwitcher.tsx +52 -0
- package/templates/fate/drizzle/client/src/ui/PostCard.tsx +284 -0
- package/templates/fate/drizzle/client/src/ui/Section.tsx +23 -0
- package/templates/fate/drizzle/client/src/ui/UserCard.tsx +131 -0
- package/templates/fate/drizzle/client/src/user/AuthClient.tsx +6 -0
- package/templates/fate/drizzle/client/translations/de_DE.json +689 -0
- package/templates/fate/drizzle/client/translations/ja_JP.json +689 -0
- package/templates/fate/drizzle/client/vite.config.ts +39 -0
- package/templates/fate/drizzle/client/void.json +29 -0
- package/templates/fate/drizzle/docker-compose.yml +11 -0
- package/templates/fate/drizzle/package.json +46 -0
- package/templates/fate/drizzle/pnpm-workspace.yaml +36 -0
- package/templates/fate/drizzle/public/fate-logo-dark.svg +4 -0
- package/templates/fate/drizzle/public/fate-logo.svg +4 -0
- package/templates/fate/drizzle/server/.env.example +5 -0
- package/templates/fate/drizzle/server/drizzle.config.ts +11 -0
- package/templates/fate/drizzle/server/package.json +48 -0
- package/templates/fate/drizzle/server/src/__tests__/index.test.tsx +5 -0
- package/templates/fate/drizzle/server/src/app.tsx +44 -0
- package/templates/fate/drizzle/server/src/drizzle/db.ts +25 -0
- package/templates/fate/drizzle/server/src/drizzle/queries.ts +89 -0
- package/templates/fate/drizzle/server/src/drizzle/schema.ts +174 -0
- package/templates/fate/drizzle/server/src/drizzle/seed.tsx +77 -0
- package/templates/fate/drizzle/server/src/drizzle/seedData.ts +205 -0
- package/templates/fate/drizzle/server/src/index.tsx +24 -0
- package/templates/fate/drizzle/server/src/lib/auth.ts +33 -0
- package/templates/fate/drizzle/server/src/lib/env.ts +3 -0
- package/templates/fate/drizzle/server/src/lib/origins.ts +18 -0
- package/templates/fate/drizzle/server/src/router.ts +15 -0
- package/templates/fate/drizzle/server/src/trpc/connection.ts +4 -0
- package/templates/fate/drizzle/server/src/trpc/context.ts +22 -0
- package/templates/fate/drizzle/server/src/trpc/init.ts +51 -0
- package/templates/fate/drizzle/server/src/trpc/routers/comment.ts +146 -0
- package/templates/fate/drizzle/server/src/trpc/routers/post.ts +99 -0
- package/templates/fate/drizzle/server/src/trpc/routers/user.ts +68 -0
- package/templates/fate/drizzle/server/src/trpc/views.ts +75 -0
- package/templates/fate/drizzle/server/src/user/SessionUser.tsx +17 -0
- package/templates/fate/drizzle/server/vite.config.ts +8 -0
- package/templates/fate/drizzle/tsconfig.json +33 -0
- package/templates/fate/drizzle/vite.config.ts +71 -0
- package/templates/fate/http/.github/workflows/push.yml +27 -0
- package/templates/fate/http/.oxfmtrc.jsonc +23 -0
- package/templates/fate/http/.vite-hooks/pre-commit +1 -0
- package/templates/fate/http/.vscode/extensions.json +12 -0
- package/templates/fate/http/.vscode/settings.json +25 -0
- package/templates/fate/http/AGENTS.md +55 -0
- package/templates/fate/http/README.md +112 -0
- package/templates/fate/http/_gitignore +20 -0
- package/templates/fate/http/client/package.json +56 -0
- package/templates/fate/http/client/pages/index.tsx +96 -0
- package/templates/fate/http/client/pages/layout.tsx +89 -0
- package/templates/fate/http/client/pages/login.tsx +107 -0
- package/templates/fate/http/client/pages/post/[id].tsx +22 -0
- package/templates/fate/http/client/pages/search.tsx +95 -0
- package/templates/fate/http/client/public/icon.svg +1 -0
- package/templates/fate/http/client/src/App.css +153 -0
- package/templates/fate/http/client/src/lib/AvailableLanguages.tsx +5 -0
- package/templates/fate/http/client/src/lib/cx.tsx +6 -0
- package/templates/fate/http/client/src/lib/env.tsx +5 -0
- package/templates/fate/http/client/src/ui/Badge.tsx +31 -0
- package/templates/fate/http/client/src/ui/Button.tsx +108 -0
- package/templates/fate/http/client/src/ui/Card.tsx +32 -0
- package/templates/fate/http/client/src/ui/CommentCard.tsx +75 -0
- package/templates/fate/http/client/src/ui/Error.tsx +19 -0
- package/templates/fate/http/client/src/ui/H2.tsx +15 -0
- package/templates/fate/http/client/src/ui/H3.tsx +20 -0
- package/templates/fate/http/client/src/ui/Header.tsx +96 -0
- package/templates/fate/http/client/src/ui/Input.tsx +17 -0
- package/templates/fate/http/client/src/ui/Link.tsx +11 -0
- package/templates/fate/http/client/src/ui/LocaleSwitcher.tsx +52 -0
- package/templates/fate/http/client/src/ui/PostCard.tsx +281 -0
- package/templates/fate/http/client/src/ui/Section.tsx +23 -0
- package/templates/fate/http/client/src/ui/UserCard.tsx +131 -0
- package/templates/fate/http/client/src/user/AuthClient.tsx +6 -0
- package/templates/fate/http/client/translations/de_DE.json +689 -0
- package/templates/fate/http/client/translations/ja_JP.json +689 -0
- package/templates/fate/http/client/vite.config.ts +40 -0
- package/templates/fate/http/client/void.json +29 -0
- package/templates/fate/http/docker-compose.yml +11 -0
- package/templates/fate/http/package.json +46 -0
- package/templates/fate/http/pnpm-workspace.yaml +36 -0
- package/templates/fate/http/public/fate-logo-dark.svg +4 -0
- package/templates/fate/http/public/fate-logo.svg +4 -0
- package/templates/fate/http/server/.env.example +5 -0
- package/templates/fate/http/server/drizzle.config.ts +11 -0
- package/templates/fate/http/server/package.json +46 -0
- package/templates/fate/http/server/src/__tests__/index.test.tsx +5 -0
- package/templates/fate/http/server/src/app.tsx +36 -0
- package/templates/fate/http/server/src/drizzle/db.ts +25 -0
- package/templates/fate/http/server/src/drizzle/queries.ts +89 -0
- package/templates/fate/http/server/src/drizzle/schema.ts +174 -0
- package/templates/fate/http/server/src/drizzle/seed.tsx +77 -0
- package/templates/fate/http/server/src/drizzle/seedData.ts +205 -0
- package/templates/fate/http/server/src/fate/context.ts +20 -0
- package/templates/fate/http/server/src/fate/server.ts +285 -0
- package/templates/fate/http/server/src/fate/views.ts +75 -0
- package/templates/fate/http/server/src/index.tsx +24 -0
- package/templates/fate/http/server/src/lib/auth.ts +33 -0
- package/templates/fate/http/server/src/lib/env.ts +3 -0
- package/templates/fate/http/server/src/lib/origins.ts +18 -0
- package/templates/fate/http/server/src/router.ts +2 -0
- package/templates/fate/http/server/src/user/SessionUser.tsx +17 -0
- package/templates/fate/http/server/vite.config.ts +8 -0
- package/templates/fate/http/tsconfig.json +33 -0
- package/templates/fate/http/vite.config.ts +71 -0
- package/templates/fate/prisma/.github/workflows/push.yml +27 -0
- package/templates/fate/prisma/.oxfmtrc.jsonc +23 -0
- package/templates/fate/prisma/.vite-hooks/pre-commit +1 -0
- package/templates/fate/prisma/.vscode/extensions.json +12 -0
- package/templates/fate/prisma/.vscode/settings.json +25 -0
- package/templates/fate/prisma/AGENTS.md +55 -0
- package/templates/fate/prisma/README.md +126 -0
- package/templates/fate/prisma/_gitignore +20 -0
- package/templates/fate/prisma/client/package.json +58 -0
- package/templates/fate/prisma/client/pages/index.tsx +96 -0
- package/templates/fate/prisma/client/pages/layout.tsx +94 -0
- package/templates/fate/prisma/client/pages/login.tsx +107 -0
- package/templates/fate/prisma/client/pages/post/[id].tsx +22 -0
- package/templates/fate/prisma/client/pages/search.tsx +95 -0
- package/templates/fate/prisma/client/public/icon.svg +1 -0
- package/templates/fate/prisma/client/src/App.css +153 -0
- package/templates/fate/prisma/client/src/lib/AvailableLanguages.tsx +5 -0
- package/templates/fate/prisma/client/src/lib/cx.tsx +6 -0
- package/templates/fate/prisma/client/src/lib/env.tsx +5 -0
- package/templates/fate/prisma/client/src/ui/Badge.tsx +31 -0
- package/templates/fate/prisma/client/src/ui/Button.tsx +108 -0
- package/templates/fate/prisma/client/src/ui/Card.tsx +32 -0
- package/templates/fate/prisma/client/src/ui/CommentCard.tsx +75 -0
- package/templates/fate/prisma/client/src/ui/Error.tsx +19 -0
- package/templates/fate/prisma/client/src/ui/H2.tsx +15 -0
- package/templates/fate/prisma/client/src/ui/H3.tsx +20 -0
- package/templates/fate/prisma/client/src/ui/Header.tsx +96 -0
- package/templates/fate/prisma/client/src/ui/Input.tsx +17 -0
- package/templates/fate/prisma/client/src/ui/Link.tsx +11 -0
- package/templates/fate/prisma/client/src/ui/LocaleSwitcher.tsx +52 -0
- package/templates/fate/prisma/client/src/ui/PostCard.tsx +281 -0
- package/templates/fate/prisma/client/src/ui/Section.tsx +23 -0
- package/templates/fate/prisma/client/src/ui/UserCard.tsx +131 -0
- package/templates/fate/prisma/client/src/user/AuthClient.tsx +6 -0
- package/templates/fate/prisma/client/translations/de_DE.json +689 -0
- package/templates/fate/prisma/client/translations/ja_JP.json +689 -0
- package/templates/fate/prisma/client/vite.config.ts +39 -0
- package/templates/fate/prisma/client/void.json +29 -0
- package/templates/fate/prisma/docker-compose.yml +11 -0
- package/templates/fate/prisma/package.json +47 -0
- package/templates/fate/prisma/pnpm-workspace.yaml +39 -0
- package/templates/fate/prisma/public/fate-logo-dark.svg +4 -0
- package/templates/fate/prisma/public/fate-logo.svg +4 -0
- package/templates/fate/prisma/server/.env.example +5 -0
- package/templates/fate/prisma/server/package.json +42 -0
- package/templates/fate/prisma/server/prisma.config.ts +21 -0
- package/templates/fate/prisma/server/src/__tests__/index.test.tsx +5 -0
- package/templates/fate/prisma/server/src/app.tsx +40 -0
- package/templates/fate/prisma/server/src/index.tsx +24 -0
- package/templates/fate/prisma/server/src/lib/auth.ts +31 -0
- package/templates/fate/prisma/server/src/lib/env.ts +3 -0
- package/templates/fate/prisma/server/src/lib/origins.ts +18 -0
- package/templates/fate/prisma/server/src/prisma/prisma.ts +9 -0
- package/templates/fate/prisma/server/src/prisma/schema.prisma +98 -0
- package/templates/fate/prisma/server/src/prisma/seed.tsx +71 -0
- package/templates/fate/prisma/server/src/prisma/seedData.ts +205 -0
- package/templates/fate/prisma/server/src/router.ts +14 -0
- package/templates/fate/prisma/server/src/trpc/connection.ts +4 -0
- package/templates/fate/prisma/server/src/trpc/context.ts +22 -0
- package/templates/fate/prisma/server/src/trpc/init.ts +8 -0
- package/templates/fate/prisma/server/src/trpc/routers/comment.ts +176 -0
- package/templates/fate/prisma/server/src/trpc/routers/post.ts +154 -0
- package/templates/fate/prisma/server/src/trpc/routers/user.ts +71 -0
- package/templates/fate/prisma/server/src/trpc/views.ts +77 -0
- package/templates/fate/prisma/server/src/user/SessionUser.tsx +17 -0
- package/templates/fate/prisma/server/vite.config.ts +8 -0
- package/templates/fate/prisma/tsconfig.json +33 -0
- package/templates/fate/prisma/vite.config.ts +72 -0
- package/templates/fate/void/.oxfmtrc.jsonc +14 -0
- package/templates/fate/void/README.md +55 -0
- package/templates/fate/void/auth.ts +22 -0
- package/templates/fate/void/db/migrations/20260508073357_initial_schema.sql +133 -0
- package/templates/fate/void/db/migrations/20260508120500_seed_void_demo.sql +170 -0
- package/templates/fate/void/db/migrations/meta/20260508073357_snapshot.json +868 -0
- package/templates/fate/void/db/migrations/meta/_journal.json +20 -0
- package/templates/fate/void/db/queries.ts +211 -0
- package/templates/fate/void/db/schema.ts +325 -0
- package/templates/fate/void/db/seed-auth.ts +31 -0
- package/templates/fate/void/db/seed.ts +163 -0
- package/templates/fate/void/middleware/01.shared.ts +29 -0
- package/templates/fate/void/package.json +65 -0
- package/templates/fate/void/pages/category/[id].tsx +23 -0
- package/templates/fate/void/pages/index.tsx +153 -0
- package/templates/fate/void/pages/layout.tsx +46 -0
- package/templates/fate/void/pages/login.tsx +103 -0
- package/templates/fate/void/pages/post/[id].tsx +20 -0
- package/templates/fate/void/pages/search.tsx +81 -0
- package/templates/fate/void/pnpm-workspace.yaml +31 -0
- package/templates/fate/void/public/.assetsignore +1 -0
- package/templates/fate/void/public/icon.svg +1 -0
- package/templates/fate/void/routes/fate-live.ts +8 -0
- package/templates/fate/void/routes/fate.ts +8 -0
- package/templates/fate/void/seedData.ts +535 -0
- package/templates/fate/void/src/App.css +130 -0
- package/templates/fate/void/src/fate/__tests__/commentPermissions.test.ts +31 -0
- package/templates/fate/void/src/fate/commentPermissions.ts +17 -0
- package/templates/fate/void/src/fate/context.ts +15 -0
- package/templates/fate/void/src/fate/live.ts +6 -0
- package/templates/fate/void/src/fate/server.ts +401 -0
- package/templates/fate/void/src/fate/views.ts +164 -0
- package/templates/fate/void/src/lib/cx.tsx +6 -0
- package/templates/fate/void/src/lib/formatLabel.tsx +6 -0
- package/templates/fate/void/src/lib/shared.ts +10 -0
- package/templates/fate/void/src/ui/Badge.tsx +31 -0
- package/templates/fate/void/src/ui/Button.tsx +92 -0
- package/templates/fate/void/src/ui/Card.tsx +29 -0
- package/templates/fate/void/src/ui/CategoryCard.tsx +105 -0
- package/templates/fate/void/src/ui/CommentCard.tsx +80 -0
- package/templates/fate/void/src/ui/CreatePost.tsx +111 -0
- package/templates/fate/void/src/ui/Error.tsx +19 -0
- package/templates/fate/void/src/ui/EventCard.tsx +152 -0
- package/templates/fate/void/src/ui/H2.tsx +15 -0
- package/templates/fate/void/src/ui/H3.tsx +20 -0
- package/templates/fate/void/src/ui/Header.tsx +96 -0
- package/templates/fate/void/src/ui/Input.tsx +33 -0
- package/templates/fate/void/src/ui/Link.tsx +11 -0
- package/templates/fate/void/src/ui/Number.tsx +5 -0
- package/templates/fate/void/src/ui/PostCard.tsx +294 -0
- package/templates/fate/void/src/ui/Section.tsx +23 -0
- package/templates/fate/void/src/ui/TagBadge.tsx +23 -0
- package/templates/fate/void/src/ui/UserCard.tsx +129 -0
- package/templates/fate/void/src/user/AuthClient.tsx +1 -0
- package/templates/fate/void/src/user/SessionUser.tsx +17 -0
- package/templates/fate/void/tsconfig.json +34 -0
- package/templates/fate/void/vite.config.ts +83 -0
- package/templates/fate/void/void.json +32 -0
- package/templates/fate/void/wrangler.jsonc +16 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import Stack, { VStack } from '@nkzw/stack';
|
|
2
|
+
import { ArrowUpRight, CalendarDays, MapPin, Users } from 'lucide-react';
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useView, view, ViewRef } from 'react-fate';
|
|
5
|
+
import type { Event, EventAttendee } from '../fate/views.ts';
|
|
6
|
+
import formatLabel from '../lib/formatLabel.tsx';
|
|
7
|
+
import { Badge } from '../ui/Badge.tsx';
|
|
8
|
+
import Card from '../ui/Card.tsx';
|
|
9
|
+
import { UserView } from '../ui/UserCard.tsx';
|
|
10
|
+
|
|
11
|
+
const EventAttendeeView = view<EventAttendee>()({
|
|
12
|
+
id: true,
|
|
13
|
+
notes: true,
|
|
14
|
+
status: true,
|
|
15
|
+
user: UserView,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const EventView = view<Event>()({
|
|
19
|
+
attendees: {
|
|
20
|
+
items: {
|
|
21
|
+
node: EventAttendeeView,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
attendingCount: true,
|
|
25
|
+
capacity: true,
|
|
26
|
+
description: true,
|
|
27
|
+
endAt: true,
|
|
28
|
+
host: UserView,
|
|
29
|
+
id: true,
|
|
30
|
+
livestreamUrl: true,
|
|
31
|
+
location: true,
|
|
32
|
+
name: true,
|
|
33
|
+
startAt: true,
|
|
34
|
+
topics: true,
|
|
35
|
+
type: true,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const EventAttendeeChip = ({ attendee: attendeeRef }: { attendee: ViewRef<'EventAttendee'> }) => {
|
|
39
|
+
const attendee = useView(EventAttendeeView, attendeeRef);
|
|
40
|
+
const user = useView(UserView, attendee.user);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Badge className="text-nowrap" key={attendee.id} variant="outline">
|
|
44
|
+
{user?.name ?? 'Guest'} · {formatLabel(attendee.status)}
|
|
45
|
+
</Badge>
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const hydrationSafeDateTimeFormatter = new Intl.DateTimeFormat('en-US', {
|
|
50
|
+
day: 'numeric',
|
|
51
|
+
hour: 'numeric',
|
|
52
|
+
minute: 'numeric',
|
|
53
|
+
month: 'short',
|
|
54
|
+
timeZone: 'UTC',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const localDateTimeFormatter = new Intl.DateTimeFormat(undefined, {
|
|
58
|
+
day: 'numeric',
|
|
59
|
+
hour: 'numeric',
|
|
60
|
+
minute: 'numeric',
|
|
61
|
+
month: 'short',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const formatDateTime = (date: string, local: boolean) =>
|
|
65
|
+
(local ? localDateTimeFormatter : hydrationSafeDateTimeFormatter).format(new Date(date));
|
|
66
|
+
|
|
67
|
+
const EventDateTime = ({ endAt, startAt }: { endAt: string; startAt: string }) => {
|
|
68
|
+
const [local, setLocal] = useState(false);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const frame = requestAnimationFrame(() => setLocal(true));
|
|
72
|
+
return () => cancelAnimationFrame(frame);
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<>
|
|
77
|
+
{formatDateTime(startAt, local)} → {formatDateTime(endAt, local)}
|
|
78
|
+
</>
|
|
79
|
+
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export default function EventCard({ event: eventRef }: { event: ViewRef<'Event'> }) {
|
|
83
|
+
const event = useView(EventView, eventRef);
|
|
84
|
+
const host = useView(UserView, event.host);
|
|
85
|
+
const attendees = event.attendees?.items ?? [];
|
|
86
|
+
const topics = event.topics ?? [];
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<Card key={event.id}>
|
|
90
|
+
<VStack gap={12}>
|
|
91
|
+
<Stack alignCenter between gap={12}>
|
|
92
|
+
<div>
|
|
93
|
+
<h4 className="text-base font-semibold text-foreground">{event.name}</h4>
|
|
94
|
+
<p className="text-sm text-muted-foreground">{event.description}</p>
|
|
95
|
+
</div>
|
|
96
|
+
<Badge className="text-nowrap" variant="secondary">
|
|
97
|
+
{formatLabel(event.type)}
|
|
98
|
+
</Badge>
|
|
99
|
+
</Stack>
|
|
100
|
+
<Stack alignCenter gap>
|
|
101
|
+
<CalendarDays className="text-muted-foreground" size={14} />
|
|
102
|
+
<span className="text-sm text-foreground/80">
|
|
103
|
+
<EventDateTime endAt={event.endAt} startAt={event.startAt} />
|
|
104
|
+
</span>
|
|
105
|
+
</Stack>
|
|
106
|
+
<Stack alignCenter gap>
|
|
107
|
+
<MapPin className="text-muted-foreground" size={14} />
|
|
108
|
+
<span className="text-sm text-foreground/80">{event.location}</span>
|
|
109
|
+
</Stack>
|
|
110
|
+
<Stack alignCenter gap>
|
|
111
|
+
<Users className="text-muted-foreground" size={14} />
|
|
112
|
+
<span className="text-sm text-foreground/80">
|
|
113
|
+
{event.attendingCount ?? attendees.length} attending · capacity {event.capacity}
|
|
114
|
+
</span>
|
|
115
|
+
</Stack>
|
|
116
|
+
<Stack alignCenter gap>
|
|
117
|
+
<ArrowUpRight className="text-muted-foreground" size={14} />
|
|
118
|
+
<span className="text-sm text-foreground/80">Hosted by {host?.name ?? 'Unknown'}</span>
|
|
119
|
+
</Stack>
|
|
120
|
+
{topics.length ? (
|
|
121
|
+
<Stack gap wrap>
|
|
122
|
+
{topics.map((topic) => (
|
|
123
|
+
<Badge className="text-nowrap" key={topic} variant="outline">
|
|
124
|
+
{topic}
|
|
125
|
+
</Badge>
|
|
126
|
+
))}
|
|
127
|
+
</Stack>
|
|
128
|
+
) : null}
|
|
129
|
+
{attendees.length ? (
|
|
130
|
+
<VStack gap>
|
|
131
|
+
<span className="text-xs text-muted-foreground">Community RSVPs</span>
|
|
132
|
+
<Stack gap wrap>
|
|
133
|
+
{attendees.slice(0, 4).map(({ node }) => (
|
|
134
|
+
<EventAttendeeChip attendee={node} key={node.id} />
|
|
135
|
+
))}
|
|
136
|
+
</Stack>
|
|
137
|
+
</VStack>
|
|
138
|
+
) : null}
|
|
139
|
+
{event.livestreamUrl ? (
|
|
140
|
+
<a
|
|
141
|
+
className="text-primary text-sm font-medium hover:underline"
|
|
142
|
+
href={event.livestreamUrl}
|
|
143
|
+
rel="noreferrer"
|
|
144
|
+
target="_blank"
|
|
145
|
+
>
|
|
146
|
+
Join livestream
|
|
147
|
+
</a>
|
|
148
|
+
) : null}
|
|
149
|
+
</VStack>
|
|
150
|
+
</Card>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import cx from '../lib/cx.tsx';
|
|
2
|
+
|
|
3
|
+
export default function H2({
|
|
4
|
+
children,
|
|
5
|
+
className,
|
|
6
|
+
}: {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
}) {
|
|
10
|
+
return (
|
|
11
|
+
<h2 className={cx('text-2xl font-semibold text-gray-900 dark:text-gray-50', className)}>
|
|
12
|
+
{children}
|
|
13
|
+
</h2>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import cx from '../lib/cx.tsx';
|
|
2
|
+
|
|
3
|
+
export default function H2({
|
|
4
|
+
children,
|
|
5
|
+
className,
|
|
6
|
+
}: {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
className?: string;
|
|
9
|
+
}) {
|
|
10
|
+
return (
|
|
11
|
+
<h3
|
|
12
|
+
className={cx(
|
|
13
|
+
'bg-linear-to-r from-slate-900 to-slate-600 bg-clip-text text-lg font-semibold tracking-widest text-transparent uppercase opacity-100 transition duration-150 hover:opacity-70 active:translate-y-[1.5px] dark:from-white dark:to-slate-200',
|
|
14
|
+
className,
|
|
15
|
+
)}
|
|
16
|
+
>
|
|
17
|
+
{children}
|
|
18
|
+
</h3>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import Stack from '@nkzw/stack';
|
|
2
|
+
import { useRouter, useShared } from '@void/react';
|
|
3
|
+
import { ChevronLeft, LogIn, LogOut, Search } from 'lucide-react';
|
|
4
|
+
import type { SharedData } from '../lib/shared.ts';
|
|
5
|
+
import AuthClient from '../user/AuthClient.tsx';
|
|
6
|
+
import { Button } from './Button.tsx';
|
|
7
|
+
import Link from './Link.tsx';
|
|
8
|
+
|
|
9
|
+
export default function Header() {
|
|
10
|
+
const router = useRouter();
|
|
11
|
+
const { auth } = useShared<SharedData>();
|
|
12
|
+
const pathname = router.path;
|
|
13
|
+
const session = auth.user;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<header className="sticky top-0 z-50 border-b border-white/60 bg-white/60 backdrop-blur dark:border-neutral-800 dark:bg-neutral-950/70">
|
|
17
|
+
<Stack
|
|
18
|
+
alignCenter
|
|
19
|
+
between
|
|
20
|
+
className="max-w-8xl relative container mx-auto h-16 px-6 lg:px-8"
|
|
21
|
+
gap={16}
|
|
22
|
+
>
|
|
23
|
+
<Link
|
|
24
|
+
href="/"
|
|
25
|
+
onClick={() =>
|
|
26
|
+
pathname !== '/' && history.state?.idx > 0 ? history.back() : router.visit('/')
|
|
27
|
+
}
|
|
28
|
+
>
|
|
29
|
+
<Stack alignCenter gap={12}>
|
|
30
|
+
<div className="squircle relative flex h-10 w-10 items-center justify-center bg-linear-to-br from-gray-100 to-gray-200 text-white shadow-lg shadow-gray-500/20 transition duration-250 hover:scale-110 active:scale-90">
|
|
31
|
+
{pathname === '/' ? (
|
|
32
|
+
<svg
|
|
33
|
+
className="h-6 w-6 text-gray-500"
|
|
34
|
+
preserveAspectRatio="xMidYMax"
|
|
35
|
+
viewBox="0 0 160 160"
|
|
36
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
37
|
+
>
|
|
38
|
+
<path
|
|
39
|
+
d="M108.301 17.465h.272c1.797-.005 3.563.177 5.331.496l.349.063c5.588 1.02 10.731 3.447 15.201 6.933l.347.267c1.579 1.26 3.083 2.684 4.386 4.227l.161.186c4.018 4.719 6.924 10.686 7.767 16.852l.033.239c.262 1.848.351 3.688.352 5.551l.001.307c-.001 1.623-.092 3.188-.386 4.788l-.05.292c-1.003 5.874-3.476 11.447-7.286 16.034l-.29.358c-1.422 1.721-3.011 3.382-4.797 4.729l-.444.354c-3.046 2.405-6.436 4.147-10.084 5.442l-.497.186c-1.766.627-3.615.974-5.465 1.266l-.364.058c-1.591.238-3.185.286-4.791.281h-.499l-1.339-.002h-1.408c-.886-.001-1.772-.001-2.656-.003-3.085-.001-6.163-.005-9.247-.008v-12.78l3.606-.006 2.282-.007c1.205-.006 2.407-.01 3.611-.011.878 0 1.754-.003 2.63-.007.462-.002.928-.005 1.389-.002 6.302.01 12.003-1.72 16.65-6.117l.291-.272c3.951-3.807 6.097-9.302 6.241-14.736.04-3.078-.476-6.048-1.68-8.892l-.104-.244c-2.382-5.559-6.98-9.439-12.504-11.675-2.204-.816-4.522-1.16-6.863-1.134-.301.004-.606.004-.909.001-1.677.003-3.304.127-4.924.591l-.331.098c-2.625.758-5.093 1.928-7.254 3.607l-.323.249c-1.046.817-2.031 1.682-2.873 2.71l-.154.179c-2.354 2.782-3.9 5.988-4.727 9.529l-.054.229c-.391 1.8-.423 3.633-.42 5.471l-.001.74c.002 1.4.001 2.801-.004 4.201l-.004 3.779c0 1.824-.001 3.649-.003 5.473l-.006 2.813-.005 6.076-.001.267-.008 8.614c-.001 2.023-.004 4.045-.006 6.066v.259l-.004 2.55-.006 5.46c0 1.331-.001 2.658-.003 3.985-.002.72-.002 1.438-.002 2.156 0 .658 0 1.317-.002 1.978v.708c.004 2.098-.07 4.214-.545 6.263l-.077.359-.161.716c-.071.302-.136.605-.199.909-.55 2.538-1.6 5.067-2.832 7.348l-.214.42c-.965 1.921-2.22 3.714-3.574 5.38l-.152.183c-.78.973-1.639 1.866-2.519 2.751l-.308.313a25.81 25.81 0 0 1-2.226 1.955l-.22.179c-3.698 2.953-7.975 5.044-12.538 6.262-.212.056-.422.115-.631.176-8.366 2.287-17.8.738-25.278-3.512-3.639-2.122-7.171-4.945-9.721-8.307-.121-.15-.241-.298-.356-.448a36.247 36.247 0 0 1-2.605-3.814l-.144-.234c-.889-1.466-1.562-3.034-2.222-4.617l-.11-.265c-3.253-7.791-2.911-17.367.184-25.133.865-2.069 1.869-4.089 3.123-5.951l.172-.261a31.91 31.91 0 0 1 2.547-3.29l.229-.263c.13-.152.266-.304.396-.452l.425-.493c1.216-1.389 2.593-2.615 4.068-3.723.208-.157.414-.315.62-.476 1.184-.901 2.437-1.665 3.729-2.4l.272-.159c1.586-.893 3.235-1.573 4.934-2.21l.253-.097c4.232-1.567 8.615-1.836 13.079-1.82 1.359 0 2.732.002 4.093.007 1.209.005 2.417.006 3.621.006 1.899.002 3.794.007 5.688.012v12.78l-3.612.013-2.282.011c-1.201.005-2.401.012-3.61.016-.876.003-1.751.005-2.629.013-.463.002-.924.005-1.387.006-4.322.005-8.193.592-12.033 2.662l-.217.114c-1.458.773-2.697 1.731-3.908 2.849-.206.185-.413.362-.63.535-.435.376-.794.788-1.16 1.232l-.153.18a21.18 21.18 0 0 0-4.474 8.651l-.094.342c-.42 1.682-.54 3.395-.536 5.127v.272c.019 5.672 2.392 10.671 6.206 14.769l.215.237c3.296 3.453 8.483 5.917 13.247 6.046.448.008.896.01 1.345.01.276 0 .553.003.835.005 4.917.019 9.864-1.73 13.577-4.995l.303-.267c.299-.263.592-.527.881-.799l.262-.247c.787-.756 1.451-1.599 2.104-2.474l.151-.201c1.929-2.606 3.117-5.723 3.635-8.909l.059-.362c.194-1.296.21-2.573.207-3.883l.003-.74v-2.02c0-.73 0-1.459.005-2.188v-3.782c0-1.827.004-3.654.007-5.481 0-.939 0-1.877.004-2.816l.003-6.082v-.268c.005-2.876.008-5.75.008-8.627l.013-8.883c.004-1.823.004-3.646.004-5.469 0-1.33.004-2.659.004-3.989.003-.721.003-1.442.003-2.159v-1.98c.005-.237.005-.473 0-.711-.011-8.523 3.072-17.023 8.941-23.268.244-.263.483-.531.717-.805.416-.48.861-.918 1.324-1.352l.252-.232a26.259 26.259 0 0 1 2.184-1.812l.46-.349c1.39-1.058 2.837-1.951 4.375-2.775l.318-.172c5.005-2.666 10.5-3.877 16.15-3.889Z"
|
|
40
|
+
fill="currentColor"
|
|
41
|
+
style={{ transformBox: 'fill-box', transformOrigin: '50% 50%' }}
|
|
42
|
+
transform="rotate(-31 0 0)"
|
|
43
|
+
/>
|
|
44
|
+
</svg>
|
|
45
|
+
) : (
|
|
46
|
+
<ChevronLeft className="h-6 w-6 text-gray-500" />
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
<div className="opacity-100 transition-opacity duration-250 hover:opacity-70">
|
|
50
|
+
<span className="bg-linear-to-r from-gray-500 to-gray-900 bg-clip-text text-xl font-semibold text-transparent dark:from-gray-200 dark:to-white">
|
|
51
|
+
<span className="italic">fate</span>
|
|
52
|
+
</span>
|
|
53
|
+
<p className="hidden text-xs text-muted-foreground sm:block">
|
|
54
|
+
A modern data client for React.
|
|
55
|
+
</p>
|
|
56
|
+
</div>
|
|
57
|
+
</Stack>
|
|
58
|
+
</Link>
|
|
59
|
+
<Stack alignCenter>
|
|
60
|
+
<Button asChild size="sm" variant="ghost">
|
|
61
|
+
<Link href="/search">
|
|
62
|
+
<Search className="h-4 w-4" />
|
|
63
|
+
<span className="hidden sm:inline">Search</span>
|
|
64
|
+
</Link>
|
|
65
|
+
</Button>
|
|
66
|
+
{session ? (
|
|
67
|
+
<>
|
|
68
|
+
<Button asChild size="sm" variant="ghost">
|
|
69
|
+
<Stack
|
|
70
|
+
alignCenter
|
|
71
|
+
as="a"
|
|
72
|
+
className="text-sm font-medium text-gray-700 transition hover:text-gray-900 dark:text-gray-200 dark:hover:text-white"
|
|
73
|
+
gap={4}
|
|
74
|
+
onClick={async () => {
|
|
75
|
+
await AuthClient.signOut();
|
|
76
|
+
router.flushAll();
|
|
77
|
+
await router.refresh({ preserveState: false });
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<LogOut className="h-4 w-4" />
|
|
81
|
+
<span className="hidden sm:inline">Logout</span>
|
|
82
|
+
</Stack>
|
|
83
|
+
</Button>
|
|
84
|
+
</>
|
|
85
|
+
) : (
|
|
86
|
+
<Button asChild size="sm" variant="ghost">
|
|
87
|
+
<Link href="/login">
|
|
88
|
+
<LogIn className="h-4 w-4" /> Login
|
|
89
|
+
</Link>
|
|
90
|
+
</Button>
|
|
91
|
+
)}
|
|
92
|
+
</Stack>
|
|
93
|
+
</Stack>
|
|
94
|
+
</header>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { InputHTMLAttributes, Ref } from 'react';
|
|
2
|
+
import cx from '../lib/cx.tsx';
|
|
3
|
+
|
|
4
|
+
export default function Input({
|
|
5
|
+
className,
|
|
6
|
+
...props
|
|
7
|
+
}: InputHTMLAttributes<HTMLInputElement> & { ref?: Ref<HTMLInputElement> }) {
|
|
8
|
+
return (
|
|
9
|
+
<input
|
|
10
|
+
className={cx(
|
|
11
|
+
'border-input squircle flex w-32 border bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 focus-visible:ring-offset-background focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 dark:bg-neutral-900/40',
|
|
12
|
+
className,
|
|
13
|
+
)}
|
|
14
|
+
{...props}
|
|
15
|
+
/>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function CheckBox({
|
|
20
|
+
className,
|
|
21
|
+
...props
|
|
22
|
+
}: InputHTMLAttributes<HTMLInputElement> & { ref?: Ref<HTMLInputElement> }) {
|
|
23
|
+
return (
|
|
24
|
+
<Input
|
|
25
|
+
className={cx(
|
|
26
|
+
"duration-150ms relative h-6 w-6 shrink-0 cursor-pointer appearance-none p-0 after:absolute after:inset-0.5 after:h-4.5 after:w-4.5 after:rounded-4xl after:bg-foreground/60 after:opacity-0 after:transition-opacity after:content-[''] after:[corner-shape:squircle] checked:border-foreground/50 checked:after:block checked:after:opacity-100 disabled:cursor-not-allowed disabled:opacity-50",
|
|
27
|
+
className,
|
|
28
|
+
)}
|
|
29
|
+
type="checkbox"
|
|
30
|
+
{...props}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Link as VoidLink } from '@void/react';
|
|
2
|
+
import type { ComponentProps } from 'react';
|
|
3
|
+
|
|
4
|
+
export type LinkProps = Omit<ComponentProps<typeof VoidLink>, 'href'> & {
|
|
5
|
+
href?: string;
|
|
6
|
+
to?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default function Link({ href, to, ...props }: LinkProps) {
|
|
10
|
+
return <VoidLink href={href ?? to ?? '/'} {...props} />;
|
|
11
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import Stack, { VStack } from '@nkzw/stack';
|
|
2
|
+
import { useShared } from '@void/react';
|
|
3
|
+
import { cx } from 'class-variance-authority';
|
|
4
|
+
import {
|
|
5
|
+
KeyboardEvent,
|
|
6
|
+
startTransition,
|
|
7
|
+
useActionState,
|
|
8
|
+
useCallback,
|
|
9
|
+
useEffect,
|
|
10
|
+
useState,
|
|
11
|
+
} from 'react';
|
|
12
|
+
import { ErrorBoundary } from 'react-error-boundary';
|
|
13
|
+
import { useFateClient, useLiveListView, useLiveView, useView, view, ViewRef } from 'react-fate';
|
|
14
|
+
import type { Category, Comment as InlineComment, Post } from '../fate/server.ts';
|
|
15
|
+
import type { SharedData } from '../lib/shared.ts';
|
|
16
|
+
import { Button } from '../ui/Button.tsx';
|
|
17
|
+
import Card from '../ui/Card.tsx';
|
|
18
|
+
import TagBadge, { TagView } from '../ui/TagBadge.tsx';
|
|
19
|
+
import { Badge } from './Badge.tsx';
|
|
20
|
+
import CommentCard, { CommentView } from './CommentCard.tsx';
|
|
21
|
+
import H3 from './H3.tsx';
|
|
22
|
+
import Link from './Link.tsx';
|
|
23
|
+
import { UserView } from './UserCard.tsx';
|
|
24
|
+
|
|
25
|
+
const CategorySummaryView = view<Category>()({
|
|
26
|
+
id: true,
|
|
27
|
+
name: true,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const CommentConnectionView = {
|
|
31
|
+
args: { first: 3 },
|
|
32
|
+
items: {
|
|
33
|
+
node: CommentView,
|
|
34
|
+
},
|
|
35
|
+
live: {
|
|
36
|
+
append: 'visible',
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const PostView = view<Post>()({
|
|
41
|
+
author: UserView,
|
|
42
|
+
category: CategorySummaryView,
|
|
43
|
+
commentCount: true,
|
|
44
|
+
comments: CommentConnectionView,
|
|
45
|
+
content: true,
|
|
46
|
+
id: true,
|
|
47
|
+
likes: true,
|
|
48
|
+
tags: {
|
|
49
|
+
items: {
|
|
50
|
+
node: TagView,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
title: true,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const CommentInput = ({
|
|
57
|
+
error,
|
|
58
|
+
post,
|
|
59
|
+
}: {
|
|
60
|
+
error?: unknown;
|
|
61
|
+
post: { commentCount: number; id: string };
|
|
62
|
+
}) => {
|
|
63
|
+
const fate = useFateClient();
|
|
64
|
+
const { auth } = useShared<SharedData>();
|
|
65
|
+
const user = auth.user;
|
|
66
|
+
const [commentText, setCommentText] = useState('');
|
|
67
|
+
|
|
68
|
+
const [result, addComment, isPending] = useActionState(async () => {
|
|
69
|
+
const content = commentText.trim();
|
|
70
|
+
|
|
71
|
+
if (!content) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const result = await fate.mutations.comment.add({
|
|
76
|
+
input: { content, postId: post.id },
|
|
77
|
+
optimistic: {
|
|
78
|
+
author: user ? { id: user.id, name: user.name } : null,
|
|
79
|
+
content,
|
|
80
|
+
id: `optimistic:${Date.now().toString(36)}`,
|
|
81
|
+
post: { commentCount: post.commentCount + 1, id: post.id },
|
|
82
|
+
},
|
|
83
|
+
view: view<InlineComment>()({
|
|
84
|
+
...CommentView,
|
|
85
|
+
post: { commentCount: true },
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
setCommentText('');
|
|
90
|
+
|
|
91
|
+
return result;
|
|
92
|
+
}, null);
|
|
93
|
+
|
|
94
|
+
const maybeSubmitComment = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
95
|
+
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
|
|
96
|
+
startTransition(addComment);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const commentingIsDisabled = isPending || commentText.trim().length === 0;
|
|
101
|
+
|
|
102
|
+
const anyServerError = error || result?.error;
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<VStack action={addComment} as="form" gap>
|
|
106
|
+
<span className="text-sm font-medium text-foreground">Add a comment</span>
|
|
107
|
+
<textarea
|
|
108
|
+
className="squircle min-h-20 w-full border border-gray-200/80 bg-gray-100/50 p-3 text-sm placeholder-gray-500 transition outline-none focus:border-gray-500 focus:ring-2 focus:ring-gray-200 disabled:opacity-50 dark:border-neutral-800 dark:bg-neutral-900/40 dark:placeholder-gray-400"
|
|
109
|
+
disabled={isPending}
|
|
110
|
+
onChange={(event) => setCommentText(event.target.value)}
|
|
111
|
+
onKeyDown={maybeSubmitComment}
|
|
112
|
+
placeholder={user?.name ? `Share your thoughts, ${user.name}!` : 'Share your thoughts...'}
|
|
113
|
+
value={commentText}
|
|
114
|
+
/>
|
|
115
|
+
{anyServerError ? (
|
|
116
|
+
<p className="text-sm text-destructive">
|
|
117
|
+
{anyServerError instanceof Error
|
|
118
|
+
? anyServerError.message
|
|
119
|
+
: 'Something went wrong. Please try again.'}
|
|
120
|
+
</p>
|
|
121
|
+
) : null}
|
|
122
|
+
<Stack end gap>
|
|
123
|
+
<Button disabled={commentingIsDisabled} size="sm" type="submit" variant="secondary">
|
|
124
|
+
Post comment
|
|
125
|
+
</Button>
|
|
126
|
+
</Stack>
|
|
127
|
+
</VStack>
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export function PostCard({ detail, post: postRef }: { detail?: boolean; post: ViewRef<'Post'> }) {
|
|
132
|
+
const fate = useFateClient();
|
|
133
|
+
const post = useLiveView(PostView, postRef);
|
|
134
|
+
const author = useView(UserView, post.author);
|
|
135
|
+
const category = useView(CategorySummaryView, post.category);
|
|
136
|
+
const [comments, loadNext] = useLiveListView(CommentConnectionView, post.comments);
|
|
137
|
+
const tags = post.tags?.items ?? [];
|
|
138
|
+
|
|
139
|
+
const [likeResult, likeAction, likeIsPending] = useActionState(fate.actions.post.like, null);
|
|
140
|
+
|
|
141
|
+
const [, unlikeAction, unlikeIsPending] = useActionState(fate.actions.post.unlike, null);
|
|
142
|
+
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (likeResult?.error) {
|
|
145
|
+
const timeout = setTimeout(() => startTransition(() => likeAction('reset')), 3000);
|
|
146
|
+
return () => clearTimeout(timeout);
|
|
147
|
+
}
|
|
148
|
+
}, [likeAction, likeResult]);
|
|
149
|
+
|
|
150
|
+
const handleLike = useCallback(
|
|
151
|
+
async (options?: { error?: 'boundary' | 'callSite'; slow?: boolean }) => {
|
|
152
|
+
likeAction({
|
|
153
|
+
input: { id: post.id, ...options },
|
|
154
|
+
optimistic: { likes: post.likes + 1 },
|
|
155
|
+
view: PostView,
|
|
156
|
+
});
|
|
157
|
+
},
|
|
158
|
+
[likeAction, post.id, post.likes],
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const handleUnlike = useCallback(async () => {
|
|
162
|
+
unlikeAction({
|
|
163
|
+
input: { id: post.id },
|
|
164
|
+
optimistic: {
|
|
165
|
+
likes: Math.max(post.likes - 1, 0),
|
|
166
|
+
},
|
|
167
|
+
view: PostView,
|
|
168
|
+
});
|
|
169
|
+
}, [post.id, post.likes, unlikeAction]);
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<Card>
|
|
173
|
+
<VStack gap={16}>
|
|
174
|
+
<Stack alignStart between gap={16} wrap>
|
|
175
|
+
<VStack gap>
|
|
176
|
+
<Link href={`/post/${post.id}`}>
|
|
177
|
+
<H3>{post.title}</H3>
|
|
178
|
+
</Link>
|
|
179
|
+
<Stack alignCenter gap wrap>
|
|
180
|
+
{category ? (
|
|
181
|
+
<Link href={`/category/${category.id}`}>
|
|
182
|
+
<Badge className="bg-blue-50 text-blue-600 transition hover:bg-blue-100 dark:bg-blue-950/50 dark:text-blue-200 dark:hover:bg-blue-900/60">
|
|
183
|
+
{category.name}
|
|
184
|
+
</Badge>
|
|
185
|
+
</Link>
|
|
186
|
+
) : null}
|
|
187
|
+
{tags.length ? (
|
|
188
|
+
<Stack gap wrap>
|
|
189
|
+
{tags.map(({ node }) => (
|
|
190
|
+
<TagBadge key={node.id} tag={node} />
|
|
191
|
+
))}
|
|
192
|
+
</Stack>
|
|
193
|
+
) : null}
|
|
194
|
+
</Stack>
|
|
195
|
+
</VStack>
|
|
196
|
+
<Stack alignCenter gap={12} wrap>
|
|
197
|
+
<Stack
|
|
198
|
+
alignCenter
|
|
199
|
+
className="squircle bg-slate-100 px-3 py-2 text-sm font-semibold text-slate-800 dark:bg-neutral-800 dark:text-white"
|
|
200
|
+
gap
|
|
201
|
+
>
|
|
202
|
+
<span>👍</span>
|
|
203
|
+
<span>
|
|
204
|
+
{post.likes} {post.likes === 1 ? 'like' : 'likes'}
|
|
205
|
+
</span>
|
|
206
|
+
</Stack>
|
|
207
|
+
<Button action={handleLike} disabled={likeIsPending} size="sm" variant="outline">
|
|
208
|
+
Like
|
|
209
|
+
</Button>
|
|
210
|
+
{detail && (
|
|
211
|
+
<Button
|
|
212
|
+
action={() => handleLike({ slow: true })}
|
|
213
|
+
disabled={likeIsPending}
|
|
214
|
+
size="sm"
|
|
215
|
+
variant="outline"
|
|
216
|
+
>
|
|
217
|
+
Like (Slow)
|
|
218
|
+
</Button>
|
|
219
|
+
)}
|
|
220
|
+
{detail && (
|
|
221
|
+
<Button
|
|
222
|
+
action={() => handleLike({ error: 'callSite' })}
|
|
223
|
+
className={cx(
|
|
224
|
+
'w-32 transition-colors duration-150',
|
|
225
|
+
likeResult?.error ? 'border-red-500 text-red-500 hover:text-red-500' : '',
|
|
226
|
+
)}
|
|
227
|
+
disabled={likeIsPending}
|
|
228
|
+
size="sm"
|
|
229
|
+
variant="outline"
|
|
230
|
+
>
|
|
231
|
+
{likeResult?.error ? 'Oops, try again!' : `Like (Error)`}
|
|
232
|
+
</Button>
|
|
233
|
+
)}
|
|
234
|
+
{detail && (
|
|
235
|
+
<Button
|
|
236
|
+
action={() => handleLike({ error: 'boundary' })}
|
|
237
|
+
disabled={likeIsPending}
|
|
238
|
+
size="sm"
|
|
239
|
+
variant="outline"
|
|
240
|
+
>
|
|
241
|
+
Like (Network Error)
|
|
242
|
+
</Button>
|
|
243
|
+
)}
|
|
244
|
+
{detail && (
|
|
245
|
+
<Button
|
|
246
|
+
onClick={() =>
|
|
247
|
+
fate.mutations.post.like({
|
|
248
|
+
input: { id: post.id },
|
|
249
|
+
optimistic: { likes: post.likes + 1 },
|
|
250
|
+
view: PostView,
|
|
251
|
+
})
|
|
252
|
+
}
|
|
253
|
+
size="sm"
|
|
254
|
+
variant="outline"
|
|
255
|
+
>
|
|
256
|
+
Like (Many)
|
|
257
|
+
</Button>
|
|
258
|
+
)}
|
|
259
|
+
<Button
|
|
260
|
+
action={handleUnlike}
|
|
261
|
+
disabled={unlikeIsPending || post.likes === 0}
|
|
262
|
+
size="sm"
|
|
263
|
+
variant="outline"
|
|
264
|
+
>
|
|
265
|
+
Unlike
|
|
266
|
+
</Button>
|
|
267
|
+
</Stack>
|
|
268
|
+
</Stack>
|
|
269
|
+
<p className="text-sm leading-relaxed text-foreground/90 lg:text-base">{post.content}</p>
|
|
270
|
+
<p className="text-sm text-muted-foreground">- {author?.name ?? 'Unknown author'}</p>
|
|
271
|
+
<VStack gap={16}>
|
|
272
|
+
<h4 className="text-base font-semibold text-foreground">
|
|
273
|
+
{post.commentCount} {post.commentCount === 1 ? 'Comment' : 'Comments'}
|
|
274
|
+
</h4>
|
|
275
|
+
{comments.length > 0 ? (
|
|
276
|
+
<VStack gap={12}>
|
|
277
|
+
{comments.map(({ node }) => (
|
|
278
|
+
<CommentCard comment={node} key={node.id} post={post} />
|
|
279
|
+
))}
|
|
280
|
+
{loadNext ? (
|
|
281
|
+
<Button onClick={loadNext} variant="ghost">
|
|
282
|
+
Load more comments
|
|
283
|
+
</Button>
|
|
284
|
+
) : null}
|
|
285
|
+
</VStack>
|
|
286
|
+
) : null}
|
|
287
|
+
<ErrorBoundary fallbackRender={({ error }) => <CommentInput error={error} post={post} />}>
|
|
288
|
+
<CommentInput post={post} />
|
|
289
|
+
</ErrorBoundary>
|
|
290
|
+
</VStack>
|
|
291
|
+
</VStack>
|
|
292
|
+
</Card>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Gap, VStack } from '@nkzw/stack';
|
|
2
|
+
import { ReactNode } from 'react';
|
|
3
|
+
import cx from '../lib/cx.tsx';
|
|
4
|
+
|
|
5
|
+
export default function Section({
|
|
6
|
+
children,
|
|
7
|
+
className,
|
|
8
|
+
gap,
|
|
9
|
+
}: {
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
className?: string;
|
|
12
|
+
gap?: Gap;
|
|
13
|
+
}) {
|
|
14
|
+
return (
|
|
15
|
+
<VStack
|
|
16
|
+
as="section"
|
|
17
|
+
className={cx('max-w-8xl container mx-auto px-4 py-6 lg:px-8 lg:py-10', className)}
|
|
18
|
+
gap={gap}
|
|
19
|
+
>
|
|
20
|
+
{children}
|
|
21
|
+
</VStack>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useView, view, ViewRef } from 'react-fate';
|
|
2
|
+
import { Tag } from '../fate/server.ts';
|
|
3
|
+
import { Badge } from './Badge.tsx';
|
|
4
|
+
|
|
5
|
+
export const TagView = view<Tag>()({
|
|
6
|
+
description: true,
|
|
7
|
+
id: true,
|
|
8
|
+
name: true,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export default function TagBadge({ tag: tagRef }: { tag: ViewRef<'Tag'> }) {
|
|
12
|
+
const tag = useView(TagView, tagRef);
|
|
13
|
+
|
|
14
|
+
if (!tag) {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Badge className="bg-secondary/70 text-secondary-foreground" variant="secondary">
|
|
20
|
+
#{tag.name}
|
|
21
|
+
</Badge>
|
|
22
|
+
);
|
|
23
|
+
}
|