a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
77 | className
78 | )}
79 | {...props}
80 | />
81 | )
82 | }
83 |
84 | function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
85 | return (
86 |
94 | )
95 | }
96 |
97 | export {
98 | Empty,
99 | EmptyHeader,
100 | EmptyTitle,
101 | EmptyDescription,
102 | EmptyContent,
103 | EmptyMedia,
104 | }
105 |
--------------------------------------------------------------------------------
/components/ui/breadcrumb.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot as SlotPrimitive } from "radix-ui"
3 | import { ChevronRight, MoreHorizontal } from "lucide-react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
8 | return
9 | }
10 |
11 | function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
12 | return (
13 |
21 | )
22 | }
23 |
24 | function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
25 | return (
26 |
31 | )
32 | }
33 |
34 | function BreadcrumbLink({
35 | asChild,
36 | className,
37 | ...props
38 | }: React.ComponentProps<"a"> & {
39 | asChild?: boolean
40 | }) {
41 | const Comp = asChild ? SlotPrimitive.Slot : "a"
42 |
43 | return (
44 |
49 | )
50 | }
51 |
52 | function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
53 | return (
54 |
62 | )
63 | }
64 |
65 | function BreadcrumbSeparator({
66 | children,
67 | className,
68 | ...props
69 | }: React.ComponentProps<"li">) {
70 | return (
71 |
80 | )
81 | }
82 |
83 | function BreadcrumbEllipsis({
84 | className,
85 | ...props
86 | }: React.ComponentProps<"span">) {
87 | return (
88 |
98 | )
99 | }
100 |
101 | export {
102 | Breadcrumb,
103 | BreadcrumbList,
104 | BreadcrumbItem,
105 | BreadcrumbLink,
106 | BreadcrumbPage,
107 | BreadcrumbSeparator,
108 | BreadcrumbEllipsis,
109 | }
110 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | export function parseColor(value: string): {
9 | isColor: boolean;
10 | hex?: string;
11 | original?: string;
12 | } {
13 | const trimmed = value.trim();
14 |
15 | const hexMatch = trimmed.match(
16 | /^#?([A-Fa-f0-9]{3,4}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{8})$/
17 | );
18 | if (hexMatch) {
19 | let hex = hexMatch[1];
20 | if (hex.length === 3) {
21 | hex = hex
22 | .split("")
23 | .map((c) => c + c)
24 | .join("");
25 | }
26 | return { isColor: true, hex: `#${hex}`, original: trimmed };
27 | }
28 |
29 | const rgbMatch = trimmed.match(
30 | /^rgba?\s*$$\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})(?:\s*,\s*[\d.]+)?\s*$$$/i
31 | );
32 | if (rgbMatch) {
33 | const r = Number.parseInt(rgbMatch[1]).toString(16).padStart(2, "0");
34 | const g = Number.parseInt(rgbMatch[2]).toString(16).padStart(2, "0");
35 | const b = Number.parseInt(rgbMatch[3]).toString(16).padStart(2, "0");
36 | return { isColor: true, hex: `#${r}${g}${b}`, original: trimmed };
37 | }
38 |
39 | const hslMatch = trimmed.match(
40 | /^hsla?\s*$$\s*(\d{1,3})\s*,\s*(\d{1,3})%?\s*,\s*(\d{1,3})%?(?:\s*,\s*[\d.]+)?\s*$$$/i
41 | );
42 | if (hslMatch) {
43 | const h = Number.parseInt(hslMatch[1]);
44 | const s = Number.parseInt(hslMatch[2]) / 100;
45 | const l = Number.parseInt(hslMatch[3]) / 100;
46 | const hex = hslToHex(h, s, l);
47 | return { isColor: true, hex, original: trimmed };
48 | }
49 |
50 | return { isColor: false };
51 | }
52 |
53 | function hslToHex(h: number, s: number, l: number): string {
54 | const c = (1 - Math.abs(2 * l - 1)) * s;
55 | const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
56 | const m = l - c / 2;
57 | let r = 0,
58 | g = 0,
59 | b = 0;
60 |
61 | if (0 <= h && h < 60) {
62 | r = c;
63 | g = x;
64 | b = 0;
65 | } else if (60 <= h && h < 120) {
66 | r = x;
67 | g = c;
68 | b = 0;
69 | } else if (120 <= h && h < 180) {
70 | r = 0;
71 | g = c;
72 | b = x;
73 | } else if (180 <= h && h < 240) {
74 | r = 0;
75 | g = x;
76 | b = c;
77 | } else if (240 <= h && h < 300) {
78 | r = x;
79 | g = 0;
80 | b = c;
81 | } else if (300 <= h && h < 360) {
82 | r = c;
83 | g = 0;
84 | b = x;
85 | }
86 |
87 | const toHex = (n: number) =>
88 | Math.round((n + m) * 255)
89 | .toString(16)
90 | .padStart(2, "0");
91 | return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
92 | }
93 |
94 | export function isUrl(value: string): boolean {
95 | return !!value.match(/^(https?:\/\/)?[\w.-]+\.[a-z]{2,}/i);
96 | }
97 |
98 | export function normalizeUrl(value: string): string {
99 | return value.startsWith("http") ? value : `https://${value}`;
100 | }
101 |
--------------------------------------------------------------------------------
/components/ui/table.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | function Table({ className, ...props }: React.ComponentProps<"table">) {
8 | return (
9 |
19 | )
20 | }
21 |
22 | function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
23 | return (
24 |
29 | )
30 | }
31 |
32 | function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
33 | return (
34 |
39 | )
40 | }
41 |
42 | function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
43 | return (
44 |
tr]:last:border-b-0",
48 | className
49 | )}
50 | {...props}
51 | />
52 | )
53 | }
54 |
55 | function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
56 | return (
57 |
65 | )
66 | }
67 |
68 | function TableHead({ className, ...props }: React.ComponentProps<"th">) {
69 | return (
70 | [role=checkbox]]:translate-y-[2px]",
74 | className
75 | )}
76 | {...props}
77 | />
78 | )
79 | }
80 |
81 | function TableCell({ className, ...props }: React.ComponentProps<"td">) {
82 | return (
83 | | [role=checkbox]]:translate-y-[2px]",
87 | className
88 | )}
89 | {...props}
90 | />
91 | )
92 | }
93 |
94 | function TableCaption({
95 | className,
96 | ...props
97 | }: React.ComponentProps<"caption">) {
98 | return (
99 |
104 | )
105 | }
106 |
107 | export {
108 | Table,
109 | TableHeader,
110 | TableBody,
111 | TableFooter,
112 | TableHead,
113 | TableRow,
114 | TableCell,
115 | TableCaption,
116 | }
117 |
--------------------------------------------------------------------------------
/components/ui/pagination.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import {
3 | ChevronLeftIcon,
4 | ChevronRightIcon,
5 | MoreHorizontalIcon,
6 | } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 | import { Button, buttonVariants } from "@/components/ui/button"
10 |
11 | function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
12 | return (
13 |
20 | )
21 | }
22 |
23 | function PaginationContent({
24 | className,
25 | ...props
26 | }: React.ComponentProps<"ul">) {
27 | return (
28 |
33 | )
34 | }
35 |
36 | function PaginationItem({ ...props }: React.ComponentProps<"li">) {
37 | return
38 | }
39 |
40 | type PaginationLinkProps = {
41 | isActive?: boolean
42 | } & Pick, "size"> &
43 | React.ComponentProps<"a">
44 |
45 | function PaginationLink({
46 | className,
47 | isActive,
48 | size = "icon",
49 | ...props
50 | }: PaginationLinkProps) {
51 | return (
52 |
65 | )
66 | }
67 |
68 | function PaginationPrevious({
69 | className,
70 | ...props
71 | }: React.ComponentProps) {
72 | return (
73 |
79 |
80 | Previous
81 |
82 | )
83 | }
84 |
85 | function PaginationNext({
86 | className,
87 | ...props
88 | }: React.ComponentProps) {
89 | return (
90 |
96 | Next
97 |
98 |
99 | )
100 | }
101 |
102 | function PaginationEllipsis({
103 | className,
104 | ...props
105 | }: React.ComponentProps<"span">) {
106 | return (
107 |
113 |
114 | More pages
115 |
116 | )
117 | }
118 |
119 | export {
120 | Pagination,
121 | PaginationContent,
122 | PaginationLink,
123 | PaginationItem,
124 | PaginationPrevious,
125 | PaginationNext,
126 | PaginationEllipsis,
127 | }
128 |
--------------------------------------------------------------------------------
/components/login-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Link from "next/link";
4 | import { useRouter } from "next/navigation";
5 | import { useForm } from "react-hook-form";
6 | import { zodResolver } from "@hookform/resolvers/zod";
7 | import { cn } from "@/lib/utils";
8 | import { Button } from "@/components/ui/button";
9 | import {
10 | Field,
11 | FieldDescription,
12 | FieldGroup,
13 | FieldLabel,
14 | } from "@/components/ui/field";
15 | import { Input } from "@/components/ui/input";
16 | import { signIn } from "@/lib/auth-client";
17 | import { loginSchema, type LoginFormData } from "@/lib/schema";
18 |
19 | export function LoginForm({
20 | className,
21 | ...props
22 | }: React.ComponentProps<"div">) {
23 | const router = useRouter();
24 |
25 | const {
26 | register,
27 | handleSubmit,
28 | setError,
29 | formState: { errors, isSubmitting },
30 | } = useForm({
31 | resolver: zodResolver(loginSchema),
32 | defaultValues: {
33 | email: "",
34 | password: "",
35 | },
36 | });
37 |
38 | const onSubmit = async (data: LoginFormData) => {
39 | const { error } = await signIn.email({
40 | email: data.email,
41 | password: data.password,
42 | });
43 |
44 | if (error) {
45 | setError("root", { message: error.message ?? "An error occurred" });
46 | return;
47 | }
48 |
49 | router.push("/");
50 | };
51 |
52 | return (
53 |
54 |
55 | Login
56 |
57 | Enter your email below to login to your account
58 |
59 |
60 |
61 |
112 |
113 | );
114 | }
115 |
--------------------------------------------------------------------------------
/lib/schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | export const userSchema = z.object({
4 | id: z.string(),
5 | name: z.string(),
6 | email: z.string(),
7 | emailVerified: z.boolean(),
8 | image: z.string().nullable().optional(),
9 | createdAt: z.date(),
10 | updatedAt: z.date(),
11 | });
12 |
13 | export const sessionDataSchema = z.object({
14 | id: z.string(),
15 | expiresAt: z.date(),
16 | token: z.string(),
17 | createdAt: z.date(),
18 | updatedAt: z.date(),
19 | ipAddress: z.string().nullable().optional(),
20 | userAgent: z.string().nullable().optional(),
21 | userId: z.string(),
22 | });
23 |
24 | export const sessionSchema = z
25 | .object({
26 | user: userSchema,
27 | session: sessionDataSchema,
28 | })
29 | .nullable();
30 |
31 | export const bookmarkTypeSchema = z.enum(["link", "color", "text"]);
32 |
33 | export const groupSchema = z.object({
34 | id: z.string(),
35 | name: z.string(),
36 | color: z.string(),
37 | userId: z.string(),
38 | createdAt: z.date(),
39 | updatedAt: z.date(),
40 | });
41 |
42 | export const groupItemSchema = z.object({
43 | id: z.string(),
44 | name: z.string(),
45 | color: z.string(),
46 | bookmarkCount: z.number().optional(),
47 | });
48 |
49 | export const bookmarkSchema = z.object({
50 | id: z.string(),
51 | title: z.string(),
52 | url: z.string().nullable().optional(),
53 | favicon: z.string().nullable().optional(),
54 | type: bookmarkTypeSchema,
55 | color: z.string().nullable().optional(),
56 | groupId: z.string(),
57 | userId: z.string(),
58 | createdAt: z.date(),
59 | updatedAt: z.date(),
60 | });
61 |
62 | export const bookmarkItemSchema = z.object({
63 | id: z.string(),
64 | title: z.string(),
65 | url: z.string().nullable(),
66 | favicon: z.string().nullable().optional(),
67 | type: z.string(),
68 | color: z.string().nullable().optional(),
69 | groupId: z.string(),
70 | createdAt: z.union([z.date(), z.string()]),
71 | });
72 |
73 | export const createBookmarkSchema = z.object({
74 | title: z.string(),
75 | url: z.string().optional(),
76 | type: bookmarkTypeSchema.default("link"),
77 | color: z.string().optional(),
78 | groupId: z.string(),
79 | });
80 |
81 | export const updateBookmarkSchema = z.object({
82 | id: z.string(),
83 | title: z.string().optional(),
84 | url: z.string().optional(),
85 | type: bookmarkTypeSchema.optional(),
86 | color: z.string().optional(),
87 | groupId: z.string().optional(),
88 | });
89 |
90 | export const createGroupSchema = z.object({
91 | name: z.string(),
92 | color: z.string(),
93 | });
94 |
95 | export const updateGroupSchema = z.object({
96 | id: z.string(),
97 | name: z.string().optional(),
98 | color: z.string().optional(),
99 | });
100 |
101 | export const listBookmarksInputSchema = z.object({
102 | groupId: z.string().optional(),
103 | });
104 |
105 | export const deleteByIdSchema = z.object({
106 | id: z.string(),
107 | });
108 |
109 | export const signupSchema = z
110 | .object({
111 | name: z.string().min(1, "Name is required"),
112 | email: z.email("Invalid email address"),
113 | password: z.string().min(8, "Password must be at least 8 characters"),
114 | confirmPassword: z.string(),
115 | })
116 | .refine((data) => data.password === data.confirmPassword, {
117 | message: "Passwords do not match",
118 | path: ["confirmPassword"],
119 | });
120 |
121 | export const loginSchema = z.object({
122 | email: z.email("Invalid email address"),
123 | password: z.string().min(8, "Password must be at least 8 characters"),
124 | });
125 |
126 | export type User = z.infer;
127 | export type SessionData = z.infer;
128 | export type Session = z.infer;
129 | export type BookmarkType = z.infer;
130 | export type Group = z.infer;
131 | export type GroupItem = z.infer;
132 | export type Bookmark = z.infer;
133 | export type BookmarkItem = z.infer;
134 | export type CreateBookmark = z.infer;
135 | export type UpdateBookmark = z.infer;
136 | export type CreateGroup = z.infer;
137 | export type UpdateGroup = z.infer;
138 | export type SignupFormData = z.infer;
139 | export type LoginFormData = z.infer;
140 |
--------------------------------------------------------------------------------
/components/signup-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Link from "next/link";
4 | import { useRouter } from "next/navigation";
5 | import { useForm } from "react-hook-form";
6 | import { zodResolver } from "@hookform/resolvers/zod";
7 | import { cn } from "@/lib/utils";
8 | import { Button } from "@/components/ui/button";
9 | import {
10 | Field,
11 | FieldDescription,
12 | FieldGroup,
13 | FieldLabel,
14 | } from "@/components/ui/field";
15 | import { Input } from "@/components/ui/input";
16 | import { signUp } from "@/lib/auth-client";
17 | import { signupSchema, type SignupFormData } from "@/lib/schema";
18 |
19 | export function SignupForm({
20 | className,
21 | ...props
22 | }: React.ComponentProps<"div">) {
23 | const router = useRouter();
24 |
25 | const {
26 | register,
27 | handleSubmit,
28 | setError,
29 | formState: { errors, isSubmitting },
30 | } = useForm({
31 | resolver: zodResolver(signupSchema),
32 | defaultValues: {
33 | name: "",
34 | email: "",
35 | password: "",
36 | confirmPassword: "",
37 | },
38 | });
39 |
40 | const onSubmit = async (data: SignupFormData) => {
41 | const { error } = await signUp.email({
42 | name: data.name,
43 | email: data.email,
44 | password: data.password,
45 | });
46 |
47 | if (error) {
48 | setError("root", { message: error.message ?? "An error occurred" });
49 | return;
50 | }
51 |
52 | router.push("/");
53 | };
54 |
55 | return (
56 |
57 |
58 | Sign up
59 |
60 | Create an account to get started
61 |
62 |
63 |
64 |
133 |
134 | );
135 | }
136 |
--------------------------------------------------------------------------------
/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { Label as LabelPrimitive, Slot as SlotPrimitive } from "radix-ui"
5 |
6 | import {
7 | Controller,
8 | FormProvider,
9 | useFormContext,
10 | useFormState,
11 | type ControllerProps,
12 | type FieldPath,
13 | type FieldValues,
14 | } from "react-hook-form"
15 |
16 | import { cn } from "@/lib/utils"
17 | import { Label } from "@/components/ui/label"
18 |
19 | const Form = FormProvider
20 |
21 | type FormFieldContextValue<
22 | TFieldValues extends FieldValues = FieldValues,
23 | TName extends FieldPath = FieldPath,
24 | > = {
25 | name: TName
26 | }
27 |
28 | const FormFieldContext = React.createContext(
29 | {} as FormFieldContextValue
30 | )
31 |
32 | const FormField = <
33 | TFieldValues extends FieldValues = FieldValues,
34 | TName extends FieldPath = FieldPath,
35 | >({
36 | ...props
37 | }: ControllerProps) => {
38 | return (
39 |
40 |
41 |
42 | )
43 | }
44 |
45 | const useFormField = () => {
46 | const fieldContext = React.useContext(FormFieldContext)
47 | const itemContext = React.useContext(FormItemContext)
48 | const { getFieldState } = useFormContext()
49 | const formState = useFormState({ name: fieldContext.name })
50 | const fieldState = getFieldState(fieldContext.name, formState)
51 |
52 | if (!fieldContext) {
53 | throw new Error("useFormField should be used within ")
54 | }
55 |
56 | const { id } = itemContext
57 |
58 | return {
59 | id,
60 | name: fieldContext.name,
61 | formItemId: `${id}-form-item`,
62 | formDescriptionId: `${id}-form-item-description`,
63 | formMessageId: `${id}-form-item-message`,
64 | ...fieldState,
65 | }
66 | }
67 |
68 | type FormItemContextValue = {
69 | id: string
70 | }
71 |
72 | const FormItemContext = React.createContext(
73 | {} as FormItemContextValue
74 | )
75 |
76 | function FormItem({ className, ...props }: React.ComponentProps<"div">) {
77 | const id = React.useId()
78 |
79 | return (
80 |
81 |
86 |
87 | )
88 | }
89 |
90 | function FormLabel({
91 | className,
92 | ...props
93 | }: React.ComponentProps) {
94 | const { error, formItemId } = useFormField()
95 |
96 | return (
97 |
104 | )
105 | }
106 |
107 | function FormControl({ ...props }: React.ComponentProps) {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | }
124 |
125 | function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
126 | const { formDescriptionId } = useFormField()
127 |
128 | return (
129 |
135 | )
136 | }
137 |
138 | function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
139 | const { error, formMessageId } = useFormField()
140 | const body = error ? String(error?.message ?? "") : props.children
141 |
142 | if (!body) {
143 | return null
144 | }
145 |
146 | return (
147 |
153 | {body}
154 |
155 | )
156 | }
157 |
158 | export {
159 | useFormField,
160 | Form,
161 | FormItem,
162 | FormLabel,
163 | FormControl,
164 | FormDescription,
165 | FormMessage,
166 | FormField,
167 | }
168 |
--------------------------------------------------------------------------------
/server/procedures/bookmarks.ts:
--------------------------------------------------------------------------------
1 | import { authed } from "../context";
2 | import { db } from "@/lib/db";
3 | import {
4 | listBookmarksInputSchema,
5 | createBookmarkSchema,
6 | updateBookmarkSchema,
7 | deleteByIdSchema,
8 | createGroupSchema,
9 | updateGroupSchema,
10 | } from "@/lib/schema";
11 | import { getUrlMetadata } from "@/lib/url-metadata";
12 | import { normalizeUrl } from "@/lib/utils";
13 | import { z } from "zod";
14 |
15 | export const listBookmarks = authed
16 | .input(listBookmarksInputSchema)
17 | .handler(async ({ context, input }) => {
18 | const bookmarks = await db.bookmark.findMany({
19 | where: {
20 | userId: context.user.id,
21 | ...(input.groupId && { groupId: input.groupId }),
22 | },
23 | orderBy: { createdAt: "desc" },
24 | });
25 | return bookmarks;
26 | });
27 |
28 | export const createBookmark = authed
29 | .input(createBookmarkSchema)
30 | .handler(async ({ context, input }) => {
31 | let title = input.title;
32 | let favicon: string | null = null;
33 | let url = input.url || null;
34 |
35 | if (input.type === "link" && input.url) {
36 | const normalizedUrl = normalizeUrl(input.url);
37 | url = normalizedUrl;
38 |
39 | const metadata = await getUrlMetadata(normalizedUrl);
40 | if (metadata.title) {
41 | title = metadata.title;
42 | }
43 | favicon = metadata.favicon;
44 | }
45 |
46 | const bookmark = await db.bookmark.create({
47 | data: {
48 | title,
49 | url,
50 | favicon,
51 | type: input.type,
52 | color: input.color,
53 | groupId: input.groupId,
54 | userId: context.user.id,
55 | },
56 | });
57 | return bookmark;
58 | });
59 |
60 | export const updateBookmark = authed
61 | .input(updateBookmarkSchema)
62 | .handler(async ({ context, input }) => {
63 | const { id, ...data } = input;
64 | const bookmark = await db.bookmark.update({
65 | where: { id, userId: context.user.id },
66 | data,
67 | });
68 | return bookmark;
69 | });
70 |
71 | export const deleteBookmark = authed
72 | .input(deleteByIdSchema)
73 | .handler(async ({ context, input }) => {
74 | await db.bookmark.delete({
75 | where: { id: input.id, userId: context.user.id },
76 | });
77 | return { success: true };
78 | });
79 |
80 | export const listGroups = authed.handler(async ({ context }) => {
81 | const groups = await db.group.findMany({
82 | where: { userId: context.user.id },
83 | orderBy: { createdAt: "asc" },
84 | include: {
85 | _count: {
86 | select: { bookmarks: true },
87 | },
88 | },
89 | });
90 | return groups.map((g) => ({
91 | id: g.id,
92 | name: g.name,
93 | color: g.color,
94 | bookmarkCount: g._count.bookmarks,
95 | }));
96 | });
97 |
98 | export const createGroup = authed
99 | .input(createGroupSchema)
100 | .handler(async ({ context, input }) => {
101 | const group = await db.group.create({
102 | data: {
103 | ...input,
104 | userId: context.user.id,
105 | },
106 | });
107 | return group;
108 | });
109 |
110 | export const updateGroup = authed
111 | .input(updateGroupSchema)
112 | .handler(async ({ context, input }) => {
113 | const { id, ...data } = input;
114 | const group = await db.group.update({
115 | where: { id, userId: context.user.id },
116 | data,
117 | });
118 | return group;
119 | });
120 |
121 | export const deleteGroup = authed
122 | .input(deleteByIdSchema)
123 | .handler(async ({ context, input }) => {
124 | await db.group.delete({
125 | where: { id: input.id, userId: context.user.id },
126 | });
127 | return { success: true };
128 | });
129 |
130 | export const refetchBookmark = authed
131 | .input(z.object({ id: z.string() }))
132 | .handler(async ({ context, input }) => {
133 | const existing = await db.bookmark.findFirst({
134 | where: { id: input.id, userId: context.user.id },
135 | });
136 |
137 | if (!existing || !existing.url) {
138 | throw new Error("Bookmark not found or has no URL");
139 | }
140 |
141 | const metadata = await getUrlMetadata(existing.url, { bypassCache: true });
142 |
143 | const bookmark = await db.bookmark.update({
144 | where: { id: input.id, userId: context.user.id },
145 | data: {
146 | title: metadata.title || existing.title,
147 | favicon: metadata.favicon,
148 | },
149 | });
150 |
151 | return bookmark;
152 | });
153 |
--------------------------------------------------------------------------------
/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { AlertDialog as AlertDialogPrimitive } from "radix-ui"
5 |
6 | import { cn } from "@/lib/utils"
7 | import { buttonVariants } from "@/components/ui/button"
8 |
9 | function AlertDialog({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function AlertDialogTrigger({
16 | ...props
17 | }: React.ComponentProps) {
18 | return (
19 |
20 | )
21 | }
22 |
23 | function AlertDialogPortal({
24 | ...props
25 | }: React.ComponentProps) {
26 | return (
27 |
28 | )
29 | }
30 |
31 | function AlertDialogOverlay({
32 | className,
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
44 | )
45 | }
46 |
47 | function AlertDialogContent({
48 | className,
49 | ...props
50 | }: React.ComponentProps) {
51 | return (
52 |
53 |
54 |
62 |
63 | )
64 | }
65 |
66 | function AlertDialogHeader({
67 | className,
68 | ...props
69 | }: React.ComponentProps<"div">) {
70 | return (
71 |
76 | )
77 | }
78 |
79 | function AlertDialogFooter({
80 | className,
81 | ...props
82 | }: React.ComponentProps<"div">) {
83 | return (
84 |
92 | )
93 | }
94 |
95 | function AlertDialogTitle({
96 | className,
97 | ...props
98 | }: React.ComponentProps) {
99 | return (
100 |
105 | )
106 | }
107 |
108 | function AlertDialogDescription({
109 | className,
110 | ...props
111 | }: React.ComponentProps) {
112 | return (
113 |
118 | )
119 | }
120 |
121 | function AlertDialogAction({
122 | className,
123 | ...props
124 | }: React.ComponentProps) {
125 | return (
126 |
130 | )
131 | }
132 |
133 | function AlertDialogCancel({
134 | className,
135 | ...props
136 | }: React.ComponentProps) {
137 | return (
138 |
142 | )
143 | }
144 |
145 | export {
146 | AlertDialog,
147 | AlertDialogPortal,
148 | AlertDialogOverlay,
149 | AlertDialogTrigger,
150 | AlertDialogContent,
151 | AlertDialogHeader,
152 | AlertDialogFooter,
153 | AlertDialogTitle,
154 | AlertDialogDescription,
155 | AlertDialogAction,
156 | AlertDialogCancel,
157 | }
158 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { Dialog as DialogPrimitive } from "radix-ui"
5 | import { XIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Dialog({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function DialogTrigger({
16 | ...props
17 | }: React.ComponentProps) {
18 | return
19 | }
20 |
21 | function DialogPortal({
22 | ...props
23 | }: React.ComponentProps) {
24 | return
25 | }
26 |
27 | function DialogClose({
28 | ...props
29 | }: React.ComponentProps) {
30 | return
31 | }
32 |
33 | function DialogOverlay({
34 | className,
35 | ...props
36 | }: React.ComponentProps) {
37 | return (
38 |
46 | )
47 | }
48 |
49 | function DialogContent({
50 | className,
51 | children,
52 | showCloseButton = true,
53 | ...props
54 | }: React.ComponentProps & {
55 | showCloseButton?: boolean
56 | }) {
57 | return (
58 |
59 |
60 |
68 | {children}
69 | {showCloseButton && (
70 |
74 |
75 | Close
76 |
77 | )}
78 |
79 |
80 | )
81 | }
82 |
83 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
84 | return (
85 |
90 | )
91 | }
92 |
93 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
94 | return (
95 |
103 | )
104 | }
105 |
106 | function DialogTitle({
107 | className,
108 | ...props
109 | }: React.ComponentProps) {
110 | return (
111 |
116 | )
117 | }
118 |
119 | function DialogDescription({
120 | className,
121 | ...props
122 | }: React.ComponentProps) {
123 | return (
124 |
129 | )
130 | }
131 |
132 | export {
133 | Dialog,
134 | DialogClose,
135 | DialogContent,
136 | DialogDescription,
137 | DialogFooter,
138 | DialogHeader,
139 | DialogOverlay,
140 | DialogPortal,
141 | DialogTitle,
142 | DialogTrigger,
143 | }
144 |
--------------------------------------------------------------------------------
/components/ui/sheet.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { Dialog as SheetPrimitive } from "radix-ui"
5 | import { XIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Sheet({ ...props }: React.ComponentProps) {
10 | return
11 | }
12 |
13 | function SheetTrigger({
14 | ...props
15 | }: React.ComponentProps) {
16 | return
17 | }
18 |
19 | function SheetClose({
20 | ...props
21 | }: React.ComponentProps) {
22 | return
23 | }
24 |
25 | function SheetPortal({
26 | ...props
27 | }: React.ComponentProps) {
28 | return
29 | }
30 |
31 | function SheetOverlay({
32 | className,
33 | ...props
34 | }: React.ComponentProps) {
35 | return (
36 |
44 | )
45 | }
46 |
47 | function SheetContent({
48 | className,
49 | children,
50 | side = "right",
51 | ...props
52 | }: React.ComponentProps & {
53 | side?: "top" | "right" | "bottom" | "left"
54 | }) {
55 | return (
56 |
57 |
58 |
74 | {children}
75 |
76 |
77 | Close
78 |
79 |
80 |
81 | )
82 | }
83 |
84 | function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
85 | return (
86 |
91 | )
92 | }
93 |
94 | function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
95 | return (
96 |
101 | )
102 | }
103 |
104 | function SheetTitle({
105 | className,
106 | ...props
107 | }: React.ComponentProps) {
108 | return (
109 |
114 | )
115 | }
116 |
117 | function SheetDescription({
118 | className,
119 | ...props
120 | }: React.ComponentProps) {
121 | return (
122 |
127 | )
128 | }
129 |
130 | export {
131 | Sheet,
132 | SheetTrigger,
133 | SheetClose,
134 | SheetContent,
135 | SheetHeader,
136 | SheetFooter,
137 | SheetTitle,
138 | SheetDescription,
139 | }
140 |
--------------------------------------------------------------------------------
/components/ui/drawer.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { Drawer as DrawerPrimitive } from "vaul"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | function Drawer({
9 | ...props
10 | }: React.ComponentProps) {
11 | return
12 | }
13 |
14 | function DrawerTrigger({
15 | ...props
16 | }: React.ComponentProps) {
17 | return
18 | }
19 |
20 | function DrawerPortal({
21 | ...props
22 | }: React.ComponentProps) {
23 | return
24 | }
25 |
26 | function DrawerClose({
27 | ...props
28 | }: React.ComponentProps) {
29 | return
30 | }
31 |
32 | function DrawerOverlay({
33 | className,
34 | ...props
35 | }: React.ComponentProps) {
36 | return (
37 |
45 | )
46 | }
47 |
48 | function DrawerContent({
49 | className,
50 | children,
51 | ...props
52 | }: React.ComponentProps) {
53 | return (
54 |
55 |
56 |
68 |
69 | {children}
70 |
71 |
72 | )
73 | }
74 |
75 | function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
76 | return (
77 |
85 | )
86 | }
87 |
88 | function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
89 | return (
90 |
95 | )
96 | }
97 |
98 | function DrawerTitle({
99 | className,
100 | ...props
101 | }: React.ComponentProps) {
102 | return (
103 |
108 | )
109 | }
110 |
111 | function DrawerDescription({
112 | className,
113 | ...props
114 | }: React.ComponentProps) {
115 | return (
116 |
121 | )
122 | }
123 |
124 | export {
125 | Drawer,
126 | DrawerPortal,
127 | DrawerOverlay,
128 | DrawerTrigger,
129 | DrawerClose,
130 | DrawerContent,
131 | DrawerHeader,
132 | DrawerFooter,
133 | DrawerTitle,
134 | DrawerDescription,
135 | }
136 |
--------------------------------------------------------------------------------
/components/ui/item.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot as SlotPrimitive } from "radix-ui"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 | import { Separator } from "@/components/ui/separator"
7 |
8 | function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
9 | return (
10 |
16 | )
17 | }
18 |
19 | function ItemSeparator({
20 | className,
21 | ...props
22 | }: React.ComponentProps) {
23 | return (
24 |
30 | )
31 | }
32 |
33 | const itemVariants = cva(
34 | "group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
35 | {
36 | variants: {
37 | variant: {
38 | default: "bg-transparent",
39 | outline: "border-border",
40 | muted: "bg-muted/50",
41 | },
42 | size: {
43 | default: "p-4 gap-4 ",
44 | sm: "py-3 px-4 gap-2.5",
45 | },
46 | },
47 | defaultVariants: {
48 | variant: "default",
49 | size: "default",
50 | },
51 | }
52 | )
53 |
54 | function Item({
55 | className,
56 | variant = "default",
57 | size = "default",
58 | asChild = false,
59 | ...props
60 | }: React.ComponentProps<"div"> &
61 | VariantProps & { asChild?: boolean }) {
62 | const Comp = asChild ? SlotPrimitive.Slot : "div"
63 | return (
64 |
71 | )
72 | }
73 |
74 | const itemMediaVariants = cva(
75 | "flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
76 | {
77 | variants: {
78 | variant: {
79 | default: "bg-transparent",
80 | icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
81 | image:
82 | "size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
83 | },
84 | },
85 | defaultVariants: {
86 | variant: "default",
87 | },
88 | }
89 | )
90 |
91 | function ItemMedia({
92 | className,
93 | variant = "default",
94 | ...props
95 | }: React.ComponentProps<"div"> & VariantProps) {
96 | return (
97 |
103 | )
104 | }
105 |
106 | function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
107 | return (
108 |
116 | )
117 | }
118 |
119 | function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
120 | return (
121 |
129 | )
130 | }
131 |
132 | function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
133 | return (
134 | a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
139 | className
140 | )}
141 | {...props}
142 | />
143 | )
144 | }
145 |
146 | function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
147 | return (
148 |
153 | )
154 | }
155 |
156 | function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
157 | return (
158 |
166 | )
167 | }
168 |
169 | function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
170 | return (
171 |
179 | )
180 | }
181 |
182 | export {
183 | Item,
184 | ItemMedia,
185 | ItemContent,
186 | ItemActions,
187 | ItemGroup,
188 | ItemSeparator,
189 | ItemTitle,
190 | ItemDescription,
191 | ItemHeader,
192 | ItemFooter,
193 | }
194 |
--------------------------------------------------------------------------------
/components/ui/command.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { Command as CommandPrimitive } from "cmdk"
5 | import { SearchIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 | import {
9 | Dialog,
10 | DialogContent,
11 | DialogDescription,
12 | DialogHeader,
13 | DialogTitle,
14 | } from "@/components/ui/dialog"
15 |
16 | function Command({
17 | className,
18 | ...props
19 | }: React.ComponentProps) {
20 | return (
21 |
29 | )
30 | }
31 |
32 | function CommandDialog({
33 | title = "Command Palette",
34 | description = "Search for a command to run...",
35 | children,
36 | className,
37 | showCloseButton = true,
38 | ...props
39 | }: React.ComponentProps & {
40 | title?: string
41 | description?: string
42 | className?: string
43 | showCloseButton?: boolean
44 | }) {
45 | return (
46 |
60 | )
61 | }
62 |
63 | function CommandInput({
64 | className,
65 | ...props
66 | }: React.ComponentProps) {
67 | return (
68 |
72 |
73 |
81 |
82 | )
83 | }
84 |
85 | function CommandList({
86 | className,
87 | ...props
88 | }: React.ComponentProps) {
89 | return (
90 |
98 | )
99 | }
100 |
101 | function CommandEmpty({
102 | ...props
103 | }: React.ComponentProps) {
104 | return (
105 |
110 | )
111 | }
112 |
113 | function CommandGroup({
114 | className,
115 | ...props
116 | }: React.ComponentProps) {
117 | return (
118 |
126 | )
127 | }
128 |
129 | function CommandSeparator({
130 | className,
131 | ...props
132 | }: React.ComponentProps) {
133 | return (
134 |
139 | )
140 | }
141 |
142 | function CommandItem({
143 | className,
144 | ...props
145 | }: React.ComponentProps) {
146 | return (
147 |
155 | )
156 | }
157 |
158 | function CommandShortcut({
159 | className,
160 | ...props
161 | }: React.ComponentProps<"span">) {
162 | return (
163 |
171 | )
172 | }
173 |
174 | export {
175 | Command,
176 | CommandDialog,
177 | CommandInput,
178 | CommandList,
179 | CommandEmpty,
180 | CommandGroup,
181 | CommandItem,
182 | CommandShortcut,
183 | CommandSeparator,
184 | }
185 |
--------------------------------------------------------------------------------
/components/ui/input-group.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { cva, type VariantProps } from "class-variance-authority"
5 |
6 | import { cn } from "@/lib/utils"
7 | import { Button } from "@/components/ui/button"
8 | import { Input } from "@/components/ui/input"
9 | import { Textarea } from "@/components/ui/textarea"
10 |
11 | function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
12 | return (
13 | textarea]:h-auto",
19 |
20 | // Variants based on alignment.
21 | "has-[>[data-align=inline-start]]:[&>input]:pl-2",
22 | "has-[>[data-align=inline-end]]:[&>input]:pr-2",
23 | "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
24 | "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
25 |
26 | // Focus state.
27 | "has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
28 |
29 | // Error state.
30 | "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
31 |
32 | className
33 | )}
34 | {...props}
35 | />
36 | )
37 | }
38 |
39 | const inputGroupAddonVariants = cva(
40 | "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
41 | {
42 | variants: {
43 | align: {
44 | "inline-start":
45 | "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
46 | "inline-end":
47 | "order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
48 | "block-start":
49 | "order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
50 | "block-end":
51 | "order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
52 | },
53 | },
54 | defaultVariants: {
55 | align: "inline-start",
56 | },
57 | }
58 | )
59 |
60 | function InputGroupAddon({
61 | className,
62 | align = "inline-start",
63 | ...props
64 | }: React.ComponentProps<"div"> & VariantProps ) {
65 | return (
66 | {
72 | if ((e.target as HTMLElement).closest("button")) {
73 | return
74 | }
75 | e.currentTarget.parentElement?.querySelector("input")?.focus()
76 | }}
77 | {...props}
78 | />
79 | )
80 | }
81 |
82 | const inputGroupButtonVariants = cva(
83 | "text-sm shadow-none flex gap-2 items-center",
84 | {
85 | variants: {
86 | size: {
87 | xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
88 | sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
89 | "icon-xs":
90 | "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
91 | "icon-sm": "size-8 p-0 has-[>svg]:p-0",
92 | },
93 | },
94 | defaultVariants: {
95 | size: "xs",
96 | },
97 | }
98 | )
99 |
100 | function InputGroupButton({
101 | className,
102 | type = "button",
103 | variant = "ghost",
104 | size = "xs",
105 | ...props
106 | }: Omit , "size"> &
107 | VariantProps) {
108 | return (
109 |
116 | )
117 | }
118 |
119 | function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
120 | return (
121 |
128 | )
129 | }
130 |
131 | function InputGroupInput({
132 | className,
133 | ...props
134 | }: React.ComponentProps<"input">) {
135 | return (
136 |
144 | )
145 | }
146 |
147 | function InputGroupTextarea({
148 | className,
149 | ...props
150 | }: React.ComponentProps<"textarea">) {
151 | return (
152 |
160 | )
161 | }
162 |
163 | export {
164 | InputGroup,
165 | InputGroupAddon,
166 | InputGroupButton,
167 | InputGroupText,
168 | InputGroupInput,
169 | InputGroupTextarea,
170 | }
171 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 | @import "tw-animate-css";
3 |
4 | @custom-variant dark (&:is(.dark *));
5 |
6 | @theme inline {
7 | --color-background: var(--background);
8 | --color-foreground: var(--foreground);
9 | --font-sans: var(--font-geist-sans);
10 | --font-mono: var(--font-geist-mono);
11 | --animate-skeleton: skeleton 2s -1s infinite linear;
12 | --color-warning-foreground: var(--warning-foreground);
13 | --color-warning: var(--warning);
14 | --color-success-foreground: var(--success-foreground);
15 | --color-success: var(--success);
16 | --color-info-foreground: var(--info-foreground);
17 | --color-info: var(--info);
18 | --color-destructive-foreground: var(--destructive-foreground);
19 | --color-sidebar-ring: var(--sidebar-ring);
20 | --color-sidebar-border: var(--sidebar-border);
21 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
22 | --color-sidebar-accent: var(--sidebar-accent);
23 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
24 | --color-sidebar-primary: var(--sidebar-primary);
25 | --color-sidebar-foreground: var(--sidebar-foreground);
26 | --color-sidebar: var(--sidebar);
27 | --color-chart-5: var(--chart-5);
28 | --color-chart-4: var(--chart-4);
29 | --color-chart-3: var(--chart-3);
30 | --color-chart-2: var(--chart-2);
31 | --color-chart-1: var(--chart-1);
32 | --color-ring: var(--ring);
33 | --color-input: var(--input);
34 | --color-border: var(--border);
35 | --color-destructive: var(--destructive);
36 | --color-accent-foreground: var(--accent-foreground);
37 | --color-accent: var(--accent);
38 | --color-muted-foreground: var(--muted-foreground);
39 | --color-muted: var(--muted);
40 | --color-secondary-foreground: var(--secondary-foreground);
41 | --color-secondary: var(--secondary);
42 | --color-primary-foreground: var(--primary-foreground);
43 | --color-primary: var(--primary);
44 | --color-popover-foreground: var(--popover-foreground);
45 | --color-popover: var(--popover);
46 | --color-card-foreground: var(--card-foreground);
47 | --color-card: var(--card);
48 | --radius-sm: calc(var(--radius) - 4px);
49 | --radius-md: calc(var(--radius) - 2px);
50 | --radius-lg: var(--radius);
51 | --radius-xl: calc(var(--radius) + 4px);
52 | --secondary: var(--secondary);
53 | --muted: var(--muted);
54 | --input: var(--input);
55 | --border: var(--border);
56 | --accent: var(--accent);
57 | @keyframes skeleton {
58 | to {
59 | background-position: -200% 0;
60 | }
61 | }
62 | @keyframes skeleton {
63 | to {
64 | background-position: -200% 0;
65 | }
66 | }
67 | }
68 |
69 | :root {
70 | --radius: 0.625rem;
71 | --background: var(--color-white);
72 | --foreground: var(--color-zinc-900);
73 | --card: var(--color-white);
74 | --card-foreground: var(--color-zinc-900);
75 | --popover: var(--color-white);
76 | --popover-foreground: var(--color-zinc-900);
77 | --primary: var(--color-zinc-800);
78 | --primary-foreground: var(--color-zinc-50);
79 | --secondary: --alpha(var(--color-black) / 4%);
80 | --secondary-foreground: var(--color-zinc-900);
81 | --muted: --alpha(var(--color-black) / 4%);
82 | --muted-foreground: var(--color-zinc-600);
83 | --accent: --alpha(var(--color-black) / 4%);
84 | --accent-foreground: var(--color-zinc-900);
85 | --destructive: var(--color-red-500);
86 | --border: --alpha(var(--color-black) / 10%);
87 | --input: --alpha(var(--color-black) / 10%);
88 | --ring: var(--color-zinc-400);
89 | --chart-1: oklch(0.646 0.222 41.116);
90 | --chart-2: oklch(0.6 0.118 184.704);
91 | --chart-3: oklch(0.398 0.07 227.392);
92 | --chart-4: oklch(0.828 0.189 84.429);
93 | --chart-5: oklch(0.769 0.188 70.08);
94 | --sidebar: oklch(0.985 0 0);
95 | --sidebar-foreground: oklch(0.145 0 0);
96 | --sidebar-primary: oklch(0.205 0 0);
97 | --sidebar-primary-foreground: oklch(0.985 0 0);
98 | --sidebar-accent: oklch(0.97 0 0);
99 | --sidebar-accent-foreground: oklch(0.205 0 0);
100 | --sidebar-border: oklch(0.922 0 0);
101 | --sidebar-ring: oklch(0.708 0 0);
102 | --destructive-foreground: var(--color-red-700);
103 | --info: var(--color-blue-500);
104 | --info-foreground: var(--color-blue-700);
105 | --success: var(--color-emerald-500);
106 | --success-foreground: var(--color-emerald-700);
107 | --warning: var(--color-amber-500);
108 | --warning-foreground: var(--color-amber-700);
109 | }
110 |
111 | .dark {
112 | --background: var(--color-zinc-950);
113 | --foreground: var(--color-zinc-100);
114 | --card: color-mix(in srgb, var(--color-zinc-900) 80%, var(--color-zinc-950));
115 | --card-foreground: var(--color-zinc-100);
116 | --popover: var(--color-zinc-900);
117 | --popover-foreground: var(--color-zinc-100);
118 | --primary: var(--color-zinc-100);
119 | --primary-foreground: var(--color-zinc-900);
120 | --secondary: --alpha(var(--color-white) / 8%);
121 | --secondary-foreground: var(--color-zinc-100);
122 | --muted: --alpha(var(--color-white) / 8%);
123 | --muted-foreground: var(--color-zinc-400);
124 | --accent: --alpha(var(--color-white) / 8%);
125 | --accent-foreground: var(--color-zinc-100);
126 | --destructive: var(--color-red-500);
127 | --border: --alpha(var(--color-white) / 12%);
128 | --input: --alpha(var(--color-white) / 12%);
129 | --ring: var(--color-zinc-500);
130 | --chart-1: oklch(0.488 0.243 264.376);
131 | --chart-2: oklch(0.696 0.17 162.48);
132 | --chart-3: oklch(0.769 0.188 70.08);
133 | --chart-4: oklch(0.627 0.265 303.9);
134 | --chart-5: oklch(0.645 0.246 16.439);
135 | --sidebar: oklch(0.205 0 0);
136 | --sidebar-foreground: oklch(0.985 0 0);
137 | --sidebar-primary: oklch(0.488 0.243 264.376);
138 | --sidebar-primary-foreground: oklch(0.985 0 0);
139 | --sidebar-accent: oklch(0.269 0 0);
140 | --sidebar-accent-foreground: oklch(0.985 0 0);
141 | --sidebar-border: oklch(1 0 0 / 10%);
142 | --sidebar-ring: oklch(0.556 0 0);
143 | --destructive-foreground: var(--color-red-400);
144 | --info: var(--color-blue-500);
145 | --info-foreground: var(--color-blue-400);
146 | --success: var(--color-emerald-500);
147 | --success-foreground: var(--color-emerald-400);
148 | --warning: var(--color-amber-500);
149 | --warning-foreground: var(--color-amber-400);
150 | }
151 |
152 | @layer base {
153 | * {
154 | @apply border-border outline-ring/50;
155 | }
156 | body {
157 | @apply bg-background text-foreground;
158 | }
159 | }
--------------------------------------------------------------------------------
/components/ui/carousel.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import useEmblaCarousel, {
5 | type UseEmblaCarouselType,
6 | } from "embla-carousel-react"
7 | import { ArrowLeft, ArrowRight } from "lucide-react"
8 |
9 | import { cn } from "@/lib/utils"
10 | import { Button } from "@/components/ui/button"
11 |
12 | type CarouselApi = UseEmblaCarouselType[1]
13 | type UseCarouselParameters = Parameters
14 | type CarouselOptions = UseCarouselParameters[0]
15 | type CarouselPlugin = UseCarouselParameters[1]
16 |
17 | type CarouselProps = {
18 | opts?: CarouselOptions
19 | plugins?: CarouselPlugin
20 | orientation?: "horizontal" | "vertical"
21 | setApi?: (api: CarouselApi) => void
22 | }
23 |
24 | type CarouselContextProps = {
25 | carouselRef: ReturnType[0]
26 | api: ReturnType[1]
27 | scrollPrev: () => void
28 | scrollNext: () => void
29 | canScrollPrev: boolean
30 | canScrollNext: boolean
31 | } & CarouselProps
32 |
33 | const CarouselContext = React.createContext(null)
34 |
35 | function useCarousel() {
36 | const context = React.useContext(CarouselContext)
37 |
38 | if (!context) {
39 | throw new Error("useCarousel must be used within a ")
40 | }
41 |
42 | return context
43 | }
44 |
45 | function Carousel({
46 | orientation = "horizontal",
47 | opts,
48 | setApi,
49 | plugins,
50 | className,
51 | children,
52 | ...props
53 | }: React.ComponentProps<"div"> & CarouselProps) {
54 | const [carouselRef, api] = useEmblaCarousel(
55 | {
56 | ...opts,
57 | axis: orientation === "horizontal" ? "x" : "y",
58 | },
59 | plugins
60 | )
61 | const [canScrollPrev, setCanScrollPrev] = React.useState(false)
62 | const [canScrollNext, setCanScrollNext] = React.useState(false)
63 |
64 | const onSelect = React.useCallback((api: CarouselApi) => {
65 | if (!api) return
66 | setCanScrollPrev(api.canScrollPrev())
67 | setCanScrollNext(api.canScrollNext())
68 | }, [])
69 |
70 | const scrollPrev = React.useCallback(() => {
71 | api?.scrollPrev()
72 | }, [api])
73 |
74 | const scrollNext = React.useCallback(() => {
75 | api?.scrollNext()
76 | }, [api])
77 |
78 | const handleKeyDown = React.useCallback(
79 | (event: React.KeyboardEvent) => {
80 | if (event.key === "ArrowLeft") {
81 | event.preventDefault()
82 | scrollPrev()
83 | } else if (event.key === "ArrowRight") {
84 | event.preventDefault()
85 | scrollNext()
86 | }
87 | },
88 | [scrollPrev, scrollNext]
89 | )
90 |
91 | React.useEffect(() => {
92 | if (!api || !setApi) return
93 | setApi(api)
94 | }, [api, setApi])
95 |
96 | React.useEffect(() => {
97 | if (!api) return
98 | onSelect(api)
99 | api.on("reInit", onSelect)
100 | api.on("select", onSelect)
101 |
102 | return () => {
103 | api?.off("select", onSelect)
104 | }
105 | }, [api, onSelect])
106 |
107 | return (
108 |
121 |
129 | {children}
130 |
131 |
132 | )
133 | }
134 |
135 | function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
136 | const { carouselRef, orientation } = useCarousel()
137 |
138 | return (
139 |
153 | )
154 | }
155 |
156 | function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
157 | const { orientation } = useCarousel()
158 |
159 | return (
160 |
171 | )
172 | }
173 |
174 | function CarouselPrevious({
175 | className,
176 | variant = "outline",
177 | size = "icon",
178 | ...props
179 | }: React.ComponentProps) {
180 | const { orientation, scrollPrev, canScrollPrev } = useCarousel()
181 |
182 | return (
183 |
201 | )
202 | }
203 |
204 | function CarouselNext({
205 | className,
206 | variant = "outline",
207 | size = "icon",
208 | ...props
209 | }: React.ComponentProps) {
210 | const { orientation, scrollNext, canScrollNext } = useCarousel()
211 |
212 | return (
213 |
231 | )
232 | }
233 |
234 | export {
235 | type CarouselApi,
236 | Carousel,
237 | CarouselContent,
238 | CarouselItem,
239 | CarouselPrevious,
240 | CarouselNext,
241 | }
242 |
--------------------------------------------------------------------------------
/lib/url-metadata.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 | import { redis } from "./redis";
3 |
4 | export interface UrlMetadata {
5 | title: string | null;
6 | favicon: string | null;
7 | fetchedAt: number;
8 | }
9 |
10 | const urlMetadataSchema = z.object({
11 | title: z.string().nullable(),
12 | favicon: z.string().nullable(),
13 | fetchedAt: z.number(),
14 | });
15 |
16 | const CACHE_TTL_SUCCESS = 60 * 60 * 24 * 7;
17 | const CACHE_TTL_FAILURE = 60 * 5;
18 |
19 | function isAllowedUrl(url: string): boolean {
20 | try {
21 | const parsed = new URL(url);
22 |
23 | if (!["http:", "https:"].includes(parsed.protocol)) {
24 | return false;
25 | }
26 |
27 | const hostname = parsed.hostname.toLowerCase();
28 |
29 | if (
30 | hostname === "localhost" ||
31 | hostname === "127.0.0.1" ||
32 | hostname === "::1" ||
33 | hostname === "0.0.0.0"
34 | ) {
35 | return false;
36 | }
37 |
38 | const ipMatch = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
39 | if (ipMatch) {
40 | const [, a, b] = ipMatch.map(Number);
41 | if (a === 10) return false;
42 | if (a === 172 && b >= 16 && b <= 31) return false;
43 | if (a === 192 && b === 168) return false;
44 | if (a === 169 && b === 254) return false;
45 | if (a === 127) return false;
46 | }
47 |
48 | return true;
49 | } catch {
50 | return false;
51 | }
52 | }
53 |
54 | export function normalizeUrlForCache(url: string): string {
55 | try {
56 | const parsed = new URL(url);
57 | const host = parsed.hostname.replace(/^www\./, "").toLowerCase();
58 | const path = parsed.pathname.replace(/\/+$/, "") || "/";
59 | return `${parsed.protocol}//${host}${path}${parsed.search}`;
60 | } catch {
61 | return url.toLowerCase();
62 | }
63 | }
64 |
65 | async function getCachedMetadata(url: string): Promise {
66 | const cacheKey = `url:metadata:${normalizeUrlForCache(url)}`;
67 | const cached = await redis.get(cacheKey);
68 | if (!cached) {
69 | return null;
70 | }
71 | const parsed = urlMetadataSchema.safeParse(JSON.parse(cached));
72 | return parsed.success ? parsed.data : null;
73 | }
74 |
75 | async function cacheMetadata(
76 | url: string,
77 | metadata: UrlMetadata,
78 | ttl: number = CACHE_TTL_SUCCESS
79 | ): Promise {
80 | const cacheKey = `url:metadata:${normalizeUrlForCache(url)}`;
81 | await redis.set(cacheKey, JSON.stringify(metadata), "EX", ttl);
82 | }
83 |
84 | interface FetchResult {
85 | metadata: UrlMetadata;
86 | success: boolean;
87 | }
88 |
89 | async function fetchUrlMetadata(url: string): Promise {
90 | if (!isAllowedUrl(url)) {
91 | return {
92 | metadata: { title: null, favicon: null, fetchedAt: Date.now() },
93 | success: false,
94 | };
95 | }
96 |
97 | try {
98 | const controller = new AbortController();
99 | const timeoutId = setTimeout(() => controller.abort(), 10000);
100 |
101 | const response = await fetch(url, {
102 | signal: controller.signal,
103 | headers: {
104 | "User-Agent": "Mozilla/5.0 (compatible; bmrks/1.0)",
105 | Accept: "text/html,application/xhtml+xml",
106 | },
107 | });
108 |
109 | clearTimeout(timeoutId);
110 |
111 | if (!response.ok) {
112 | throw new Error(`HTTP ${response.status}`);
113 | }
114 |
115 | const html = await response.text();
116 | const metadata = parseHtmlMetadata(html, url);
117 |
118 | return {
119 | metadata: { ...metadata, fetchedAt: Date.now() },
120 | success: true,
121 | };
122 | } catch {
123 | return {
124 | metadata: { title: null, favicon: null, fetchedAt: Date.now() },
125 | success: false,
126 | };
127 | }
128 | }
129 |
130 | function parseHtmlMetadata(
131 | html: string,
132 | baseUrl: string
133 | ): Omit {
134 | let title: string | null = null;
135 | let favicon: string | null = null;
136 |
137 | const ogTitleMatch = html.match(
138 | /]*property=["']og:title["'][^>]*content=["']([^"']+)["']/i
139 | );
140 | if (ogTitleMatch) {
141 | title = decodeHtmlEntities(ogTitleMatch[1]);
142 | } else {
143 | const ogTitleMatchAlt = html.match(
144 | /]*content=["']([^"']+)["'][^>]*property=["']og:title["']/i
145 | );
146 | if (ogTitleMatchAlt) {
147 | title = decodeHtmlEntities(ogTitleMatchAlt[1]);
148 | } else {
149 | const titleMatch = html.match(/]*>([^<]+)<\/title>/i);
150 | if (titleMatch) {
151 | title = decodeHtmlEntities(titleMatch[1].trim());
152 | }
153 | }
154 | }
155 |
156 | const iconPatterns = [
157 | /]*rel=["']apple-touch-icon["'][^>]*href=["']([^"']+)["']/i,
158 | /]*href=["']([^"']+)["'][^>]*rel=["']apple-touch-icon["']/i,
159 | /]*rel=["']icon["'][^>]*href=["']([^"']+)["']/i,
160 | /]*href=["']([^"']+)["'][^>]*rel=["']icon["']/i,
161 | /]*rel=["']shortcut icon["'][^>]*href=["']([^"']+)["']/i,
162 | /]*href=["']([^"']+)["'][^>]*rel=["']shortcut icon["']/i,
163 | ];
164 |
165 | for (const pattern of iconPatterns) {
166 | const match = html.match(pattern);
167 | if (match) {
168 | const resolved = resolveUrl(match[1], baseUrl);
169 | if (resolved) {
170 | favicon = resolved;
171 | break;
172 | }
173 | }
174 | }
175 |
176 | if (!favicon) {
177 | try {
178 | const parsed = new URL(baseUrl);
179 | favicon = `${parsed.protocol}//${parsed.host}/favicon.ico`;
180 | } catch {}
181 | }
182 |
183 | return { title, favicon };
184 | }
185 |
186 | function resolveUrl(href: string, baseUrl: string): string | null {
187 | try {
188 | return new URL(href, baseUrl).href;
189 | } catch {
190 | return null;
191 | }
192 | }
193 |
194 | function decodeHtmlEntities(text: string): string {
195 | return text
196 | .replace(/&/g, "&")
197 | .replace(/</g, "<")
198 | .replace(/>/g, ">")
199 | .replace(/"/g, '"')
200 | .replace(/'/g, "'")
201 | .replace(/'/g, "'")
202 | .replace(///g, "/")
203 | .replace(/ /g, " ");
204 | }
205 |
206 | export async function getUrlMetadata(
207 | url: string,
208 | options: { bypassCache?: boolean } = {}
209 | ): Promise {
210 | if (!options.bypassCache) {
211 | const cached = await getCachedMetadata(url);
212 | if (cached) {
213 | return cached;
214 | }
215 | }
216 |
217 | const { metadata, success } = await fetchUrlMetadata(url);
218 |
219 | const ttl = success ? CACHE_TTL_SUCCESS : CACHE_TTL_FAILURE;
220 | await cacheMetadata(url, metadata, ttl);
221 |
222 | return metadata;
223 | }
224 |
--------------------------------------------------------------------------------
/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { Select as SelectPrimitive } from "radix-ui"
5 | import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | function Select({
10 | ...props
11 | }: React.ComponentProps) {
12 | return
13 | }
14 |
15 | function SelectGroup({
16 | ...props
17 | }: React.ComponentProps) {
18 | return
19 | }
20 |
21 | function SelectValue({
22 | ...props
23 | }: React.ComponentProps) {
24 | return
25 | }
26 |
27 | function SelectTrigger({
28 | className,
29 | size = "default",
30 | children,
31 | ...props
32 | }: React.ComponentProps & {
33 | size?: "sm" | "default"
34 | }) {
35 | return (
36 |
45 | {children}
46 |
47 |
48 |
49 |
50 | )
51 | }
52 |
53 | function SelectContent({
54 | className,
55 | children,
56 | position = "popper",
57 | align = "center",
58 | ...props
59 | }: React.ComponentProps) {
60 | return (
61 |
62 |
74 |
75 |
82 | {children}
83 |
84 |
85 |
86 |
87 | )
88 | }
89 |
90 | function SelectLabel({
91 | className,
92 | ...props
93 | }: React.ComponentProps) {
94 | return (
95 |
100 | )
101 | }
102 |
103 | function SelectItem({
104 | className,
105 | children,
106 | ...props
107 | }: React.ComponentProps) {
108 | return (
109 |
117 |
118 |
119 |
120 |
121 |
122 | {children}
123 |
124 | )
125 | }
126 |
127 | function SelectSeparator({
128 | className,
129 | ...props
130 | }: React.ComponentProps) {
131 | return (
132 |
137 | )
138 | }
139 |
140 | function SelectScrollUpButton({
141 | className,
142 | ...props
143 | }: React.ComponentProps) {
144 | return (
145 |
153 |
154 |
155 | )
156 | }
157 |
158 | function SelectScrollDownButton({
159 | className,
160 | ...props
161 | }: React.ComponentProps) {
162 | return (
163 |
171 |
172 |
173 | )
174 | }
175 |
176 | export {
177 | Select,
178 | SelectContent,
179 | SelectGroup,
180 | SelectItem,
181 | SelectLabel,
182 | SelectScrollDownButton,
183 | SelectScrollUpButton,
184 | SelectSeparator,
185 | SelectTrigger,
186 | SelectValue,
187 | }
188 |
--------------------------------------------------------------------------------
/components/ui/field.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useMemo } from "react";
4 | import { cva, type VariantProps } from "class-variance-authority";
5 |
6 | import { cn } from "@/lib/utils";
7 | import { Label } from "@/components/ui/label";
8 | import { Separator } from "@/components/ui/separator";
9 |
10 | function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
11 | return (
12 | |