Skip to content

Commit b9e15eb

Browse files
committed
Add new tweet
1 parent 8745da8 commit b9e15eb

File tree

9 files changed

+176
-9
lines changed

9 files changed

+176
-9
lines changed

app/components/error-list.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export function ErrorList({
2+
id,
3+
errors,
4+
}: {
5+
id?: string
6+
errors?: Array<string> | null
7+
}) {
8+
return errors?.length ? (
9+
<ul id={id} className="flex flex-col gap-1">
10+
{errors.map((error, i) => (
11+
<li key={i} className="text-sm text-destructive">
12+
{error}
13+
</li>
14+
))}
15+
</ul>
16+
) : null
17+
}

app/components/tweet-card.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export function TweetCard({
7474
</DropdownMenuTrigger>
7575
<DropdownMenuContent side="bottom" align="end">
7676
<DropdownMenuItem asChild>
77-
<Link to={`/tweet/${tweetMeta.id}`}>Edit</Link>
77+
<Link to={`/edit/${tweetMeta.id}`}>Edit</Link>
7878
</DropdownMenuItem>
7979
<DropdownMenuItem>Delete</DropdownMenuItem>
8080
<DropdownMenuSeparator />

app/root.tsx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,7 @@ import {getEnv} from './utils/env.server'
1616
export function meta(): ReturnType<MetaFunction> {
1717
return [
1818
{title: 'Tweet Archive'},
19-
{
20-
name: 'description',
21-
content: 'A curated archive of all your saved tweets.',
22-
},
19+
{name: 'description', content: 'A list of all your saved tweets.'},
2320
]
2421
}
2522

@@ -46,7 +43,7 @@ function App() {
4643
<div>
4744
<h1 className="text-2xl font-bold">Tweet Archive</h1>
4845
<h2 className="text-muted-foreground">
49-
Here is a list of all your saved tweets
46+
A list of all your saved tweets
5047
</h2>
5148
</div>
5249
</header>

app/routes/_index.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import {getAuth} from '@clerk/remix/ssr.server'
22
import {redirect, type LoaderFunctionArgs} from '@remix-run/node'
3-
import {useLoaderData, useNavigation, useSearchParams} from '@remix-run/react'
3+
import {
4+
Link,
5+
useLoaderData,
6+
useNavigation,
7+
useSearchParams,
8+
} from '@remix-run/react'
49
import {CirclePlus, X} from 'lucide-react'
510
import * as React from 'react'
611
import {PaginationBar} from '~/components/pagination-bar'
@@ -103,8 +108,10 @@ function Filters() {
103108
</Button>
104109
) : null}
105110
</div>
106-
<Button variant="default" size="sm" className="h-8 shadow-sm">
107-
<CirclePlus size={15} className="mr-2" /> Add tweet
111+
<Button asChild variant="default" size="sm" className="h-8 shadow-sm">
112+
<Link to="new">
113+
<CirclePlus size={15} className="mr-2" /> Add tweet
114+
</Link>
108115
</Button>
109116
</div>
110117
)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import {useParams} from '@remix-run/react'
2+
3+
export default function EditTweetPage() {
4+
const params = useParams<{tweetId: string}>()
5+
return <p>Editing tweet: {params.tweetId}</p>
6+
}

app/routes/_tweet.new.tsx

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import {getAuth} from '@clerk/remix/ssr.server'
2+
import {
3+
getFormProps,
4+
getInputProps,
5+
getTextareaProps,
6+
useForm,
7+
} from '@conform-to/react'
8+
import {getZodConstraint, parseWithZod} from '@conform-to/zod'
9+
import type {ActionFunctionArgs, LoaderFunctionArgs} from '@remix-run/node'
10+
import {
11+
Form,
12+
json,
13+
Link,
14+
redirect,
15+
useActionData,
16+
useLoaderData,
17+
useNavigation,
18+
} from '@remix-run/react'
19+
import {AlertCircle, Loader2} from 'lucide-react'
20+
import {z} from 'zod'
21+
import {ErrorList} from '~/components/error-list'
22+
import {TagsFilter} from '~/components/tags-filter'
23+
import {Alert, AlertDescription, AlertTitle} from '~/components/ui/alert'
24+
import {Button} from '~/components/ui/button'
25+
import {Input} from '~/components/ui/input'
26+
import {Label} from '~/components/ui/label'
27+
import {Textarea} from '~/components/ui/textarea'
28+
import {getTags} from '~/db/models/tags'
29+
30+
export const schema = z.object({
31+
tweetUrl: z
32+
.string({required_error: 'Tweet URL is required'})
33+
.trim()
34+
.min(1, {message: 'Tweet URL is required'}),
35+
description: z.string().optional(),
36+
})
37+
38+
export async function loader(args: LoaderFunctionArgs) {
39+
const {userId} = await getAuth(args)
40+
41+
if (!userId) {
42+
return redirect('/sign-in')
43+
}
44+
45+
const tags = await getTags(userId)
46+
return {tags}
47+
}
48+
49+
export async function action({request}: ActionFunctionArgs) {
50+
const formData = await request.formData()
51+
const result = parseWithZod(formData, {schema})
52+
if (result.status !== 'success') {
53+
return json(
54+
{
55+
status: 'error',
56+
result: result.reply(),
57+
} as const,
58+
{status: result.status === 'error' ? 400 : 200},
59+
)
60+
}
61+
62+
return redirect('/')
63+
}
64+
65+
export default function Tweet() {
66+
const {tags} = useLoaderData<typeof loader>()
67+
const actionData = useActionData<typeof action>()
68+
const navigation = useNavigation()
69+
const submitting = navigation.state === 'submitting'
70+
const [form, fields] = useForm({
71+
id: 'new-tweet',
72+
constraint: getZodConstraint(schema),
73+
lastResult: actionData?.result,
74+
onValidate({formData}) {
75+
return parseWithZod(formData, {schema})
76+
},
77+
})
78+
79+
return (
80+
<Form method="POST" {...getFormProps(form)}>
81+
{form.errors?.length ? (
82+
<div className="mb-4">
83+
<Alert variant="destructive">
84+
<AlertCircle className="h-4 w-4" />
85+
<AlertTitle>Something went wrong</AlertTitle>
86+
<AlertDescription>
87+
<ErrorList id={form.errorId} errors={form.errors} />
88+
</AlertDescription>
89+
</Alert>
90+
</div>
91+
) : null}
92+
93+
<TagsFilter tags={tags} />
94+
95+
<div className="mb-4 mt-4">
96+
<Label htmlFor={fields.tweetUrl.id}>Tweet URL</Label>
97+
<Input {...getInputProps(fields.tweetUrl, {type: 'text'})} />
98+
<ErrorList id={fields.tweetUrl.id} errors={fields.tweetUrl.errors} />
99+
</div>
100+
<div className="mb-4">
101+
<Label htmlFor={fields.description.id}>Description</Label>
102+
<Textarea {...getTextareaProps(fields.description)} />
103+
<ErrorList
104+
id={fields.description.id}
105+
errors={fields.description.errors}
106+
/>
107+
</div>
108+
109+
<div className="flex items-center justify-end gap-2">
110+
<Button asChild variant="destructive">
111+
<Link to="/">Cancel</Link>
112+
</Button>
113+
<Button type="submit" variant="default" disabled={submitting}>
114+
{submitting ? <Loader2 className="mr-2 animate-spin" /> : null}
115+
Save
116+
</Button>
117+
</div>
118+
</Form>
119+
)
120+
}

app/routes/_tweet.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import {Outlet, useLocation} from '@remix-run/react'
2+
import {Separator} from '~/components/ui/separator'
3+
4+
export default function TweetLayout() {
5+
const location = useLocation()
6+
const tweetId = location.pathname.split('/').pop()
7+
const isNewTweet = tweetId === 'new'
8+
9+
return (
10+
<div className="mx-auto mt-4 w-full max-w-96">
11+
<h2 className="mb-2 text-xl font-bold">
12+
{isNewTweet ? 'Add tweet' : `Edit tweet ${tweetId}`}
13+
</h2>
14+
<Separator className="mb-4" />
15+
<Outlet />
16+
</div>
17+
)
18+
}

bun.lockb

1.14 KB
Binary file not shown.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
},
2323
"dependencies": {
2424
"@clerk/remix": "^4.2.13",
25+
"@conform-to/react": "^1.1.5",
26+
"@conform-to/zod": "^1.1.5",
2527
"@libsql/client": "^0.9.0",
2628
"@radix-ui/react-dialog": "^1.1.1",
2729
"@radix-ui/react-dropdown-menu": "^2.1.1",

0 commit comments

Comments
 (0)