├── app ├── favicon.ico ├── loading.tsx ├── api │ └── auth │ │ └── [...all] │ │ └── route.ts ├── login │ └── page.tsx ├── signup │ └── page.tsx ├── rpc │ └── [[...rest]] │ │ └── route.ts ├── layout.tsx ├── page.tsx └── globals.css ├── server ├── index.ts ├── router.ts ├── context.ts └── procedures │ └── bookmarks.ts ├── postcss.config.mjs ├── public ├── vercel.svg ├── window.svg ├── file.svg ├── globe.svg └── next.svg ├── .mcp.json ├── .cursor └── mcp.json ├── lib ├── auth-client.ts ├── orpc.server.ts ├── auth-server.ts ├── redis.ts ├── db.ts ├── query-provider.tsx ├── orpc.ts ├── auth.ts ├── utils.ts ├── schema.ts └── url-metadata.ts ├── docker-compose.yml ├── .claude └── settings.local.json ├── prisma.config.ts ├── components ├── ui │ ├── aspect-ratio.tsx │ ├── skeleton.tsx │ ├── spinner.tsx │ ├── label.tsx │ ├── separator.tsx │ ├── textarea.tsx │ ├── progress.tsx │ ├── collapsible.tsx │ ├── kbd.tsx │ ├── input.tsx │ ├── sonner.tsx │ ├── switch.tsx │ ├── avatar.tsx │ ├── checkbox.tsx │ ├── radio-group.tsx │ ├── hover-card.tsx │ ├── toggle.tsx │ ├── popover.tsx │ ├── badge.tsx │ ├── scroll-area.tsx │ ├── alert.tsx │ ├── tooltip.tsx │ ├── tabs.tsx │ ├── slider.tsx │ ├── resizable.tsx │ ├── accordion.tsx │ ├── card.tsx │ ├── button.tsx │ ├── input-otp.tsx │ ├── button-group.tsx │ ├── toggle-group.tsx │ ├── empty.tsx │ ├── breadcrumb.tsx │ ├── table.tsx │ ├── pagination.tsx │ ├── form.tsx │ ├── alert-dialog.tsx │ ├── dialog.tsx │ ├── sheet.tsx │ ├── drawer.tsx │ ├── item.tsx │ ├── command.tsx │ ├── input-group.tsx │ ├── carousel.tsx │ ├── select.tsx │ ├── field.tsx │ ├── navigation-menu.tsx │ ├── calendar.tsx │ ├── context-menu.tsx │ └── dropdown-menu.tsx ├── header-skeleton.tsx ├── dashboard-skeleton.tsx ├── bookmark-input.tsx ├── login-form.tsx └── signup-form.tsx ├── next.config.ts ├── components.json ├── eslint.config.mjs ├── hooks └── use-mobile.ts ├── .gitignore ├── tsconfig.json ├── package.json └── prisma └── schema.prisma /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ephraimduncan/bmrks/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /server/index.ts: -------------------------------------------------------------------------------- 1 | export { router, type Router } from "./router"; 2 | export { base, authed } from "./context"; 3 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/loading.tsx: -------------------------------------------------------------------------------- 1 | import { DashboardSkeleton } from "@/components/dashboard-skeleton"; 2 | 3 | export default function Loading() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /.mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "shadcn": { 4 | "command": "npx", 5 | "args": [ 6 | "shadcn@latest", 7 | "mcp" 8 | ] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /app/api/auth/[...all]/route.ts: -------------------------------------------------------------------------------- 1 | import { auth } from "@/lib/auth"; 2 | import { toNextJsHandler } from "better-auth/next-js"; 3 | 4 | export const { GET, POST } = toNextJsHandler(auth.handler); 5 | 6 | -------------------------------------------------------------------------------- /.cursor/mcp.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "shadcn": { 4 | "command": "npx", 5 | "args": [ 6 | "shadcn@latest", 7 | "mcp" 8 | ] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /lib/auth-client.ts: -------------------------------------------------------------------------------- 1 | import { createAuthClient } from "better-auth/react"; 2 | 3 | export const authClient = createAuthClient(); 4 | 5 | export const { signIn, signUp, signOut, useSession } = authClient; 6 | 7 | -------------------------------------------------------------------------------- /lib/orpc.server.ts: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | 3 | import { createRouterClient } from "@orpc/server"; 4 | import { router } from "@/server"; 5 | 6 | export const serverClient = createRouterClient(router); 7 | 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | redis: 3 | image: redis:7-alpine 4 | ports: 5 | - "6379:6379" 6 | volumes: 7 | - redis_data:/data 8 | command: redis-server --appendonly yes 9 | 10 | volumes: 11 | redis_data: 12 | -------------------------------------------------------------------------------- /.claude/settings.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "allow": [ 4 | "Bash(npx tsc:*)", 5 | "Bash(find:*)", 6 | "Bash(npx prisma generate:*)", 7 | "Bash(npx prisma db push:*)" 8 | ], 9 | "deny": [], 10 | "ask": [] 11 | }, 12 | "outputStyle": "Explanatory" 13 | } 14 | -------------------------------------------------------------------------------- /prisma.config.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import { defineConfig, env } from "prisma/config"; 3 | 4 | export default defineConfig({ 5 | schema: "prisma/schema.prisma", 6 | migrations: { 7 | path: "prisma/migrations", 8 | }, 9 | datasource: { 10 | url: env("DATABASE_URL"), 11 | }, 12 | }); 13 | -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { LoginForm } from "@/components/login-form"; 2 | 3 | export default function Page() { 4 | return ( 5 |
6 |
7 | 8 |
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /app/signup/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignupForm } from "@/components/signup-form"; 2 | 3 | export default function Page() { 4 | return ( 5 |
6 |
7 | 8 |
9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { AspectRatio as AspectRatioPrimitive } from "radix-ui" 4 | 5 | function AspectRatio({ 6 | ...props 7 | }: React.ComponentProps) { 8 | return 9 | } 10 | 11 | export { AspectRatio } 12 | -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 4 | return ( 5 |
10 | ) 11 | } 12 | 13 | export { Skeleton } 14 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | serverExternalPackages: [ 5 | "@libsql/client", 6 | "@libsql/core", 7 | "@libsql/hrana-client", 8 | "@libsql/isomorphic-fetch", 9 | "@libsql/isomorphic-ws", 10 | "libsql", 11 | "@prisma/adapter-libsql", 12 | ], 13 | }; 14 | 15 | export default nextConfig; 16 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/ui/spinner.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2Icon } from "lucide-react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Spinner({ className, ...props }: React.ComponentProps<"svg">) { 6 | return ( 7 | 13 | ) 14 | } 15 | 16 | export { Spinner } 17 | -------------------------------------------------------------------------------- /lib/auth-server.ts: -------------------------------------------------------------------------------- 1 | import "server-only"; 2 | 3 | import { headers } from "next/headers"; 4 | import { auth } from "./auth"; 5 | import type { Session } from "./schema"; 6 | 7 | export async function getSession(): Promise { 8 | const headersList = await headers(); 9 | const session = (await auth.api.getSession({ 10 | headers: headersList, 11 | })) as Session | null; 12 | 13 | return session; 14 | } 15 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "iconLibrary": "lucide", 14 | "aliases": { 15 | "components": "@/components", 16 | "utils": "@/lib/utils", 17 | "ui": "@/components/ui", 18 | "lib": "@/lib", 19 | "hooks": "@/hooks" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /lib/redis.ts: -------------------------------------------------------------------------------- 1 | import Redis from "ioredis"; 2 | 3 | declare global { 4 | var redis: Redis | undefined; 5 | } 6 | 7 | function createRedisClient() { 8 | const url = process.env.REDIS_URL; 9 | if (!url) { 10 | throw new Error("REDIS_URL environment variable is not set"); 11 | } 12 | return new Redis(url); 13 | } 14 | 15 | const redisClient = globalThis.redis ?? createRedisClient(); 16 | 17 | if (process.env.NODE_ENV !== "production") { 18 | globalThis.redis = redisClient; 19 | } 20 | 21 | export const redis = redisClient; 22 | -------------------------------------------------------------------------------- /lib/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@/prisma/generated/client/client"; 2 | import { PrismaLibSql } from "@prisma/adapter-libsql"; 3 | 4 | const globalForPrisma = globalThis as unknown as { 5 | prisma: PrismaClient | undefined; 6 | }; 7 | 8 | const adapter = new PrismaLibSql({ 9 | url: process.env.DATABASE_URL ?? "file:./dev.db", 10 | }); 11 | 12 | export const db = 13 | globalForPrisma.prisma ?? 14 | new PrismaClient({ 15 | adapter, 16 | }); 17 | 18 | if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db; 19 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig, globalIgnores } from "eslint/config"; 2 | import nextVitals from "eslint-config-next/core-web-vitals"; 3 | import nextTs from "eslint-config-next/typescript"; 4 | 5 | const eslintConfig = defineConfig([ 6 | ...nextVitals, 7 | ...nextTs, 8 | // Override default ignores of eslint-config-next. 9 | globalIgnores([ 10 | // Default ignores of eslint-config-next: 11 | ".next/**", 12 | "out/**", 13 | "build/**", 14 | "next-env.d.ts", 15 | ]), 16 | ]); 17 | 18 | export default eslintConfig; 19 | -------------------------------------------------------------------------------- /lib/query-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 5 | 6 | export function QueryProvider({ children }: { children: React.ReactNode }) { 7 | const [queryClient] = useState( 8 | () => 9 | new QueryClient({ 10 | defaultOptions: { 11 | queries: { 12 | staleTime: 60 * 1000, 13 | }, 14 | }, 15 | }) 16 | ); 17 | 18 | return ( 19 | {children} 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /hooks/use-mobile.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | const MOBILE_BREAKPOINT = 768 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState(undefined) 7 | 8 | React.useEffect(() => { 9 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`) 10 | const onChange = () => { 11 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 12 | } 13 | mql.addEventListener("change", onChange) 14 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT) 15 | return () => mql.removeEventListener("change", onChange) 16 | }, []) 17 | 18 | return !!isMobile 19 | } 20 | -------------------------------------------------------------------------------- /lib/orpc.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type { RouterClient } from "@orpc/server"; 4 | import { RPCLink } from "@orpc/client/fetch"; 5 | import { createORPCClient } from "@orpc/client"; 6 | import { createTanstackQueryUtils } from "@orpc/tanstack-query"; 7 | import type { Router } from "@/server"; 8 | 9 | const link = new RPCLink({ 10 | url: () => { 11 | if (typeof window === "undefined") { 12 | throw new Error("RPCLink is not allowed on the server side."); 13 | } 14 | return `${window.location.origin}/rpc`; 15 | }, 16 | }); 17 | 18 | export const client: RouterClient = createORPCClient(link); 19 | 20 | export const orpc = createTanstackQueryUtils(client); 21 | -------------------------------------------------------------------------------- /server/router.ts: -------------------------------------------------------------------------------- 1 | import { base } from "./context"; 2 | import { 3 | listBookmarks, 4 | createBookmark, 5 | updateBookmark, 6 | deleteBookmark, 7 | refetchBookmark, 8 | listGroups, 9 | createGroup, 10 | updateGroup, 11 | deleteGroup, 12 | } from "./procedures/bookmarks"; 13 | 14 | export const router = base.router({ 15 | bookmark: { 16 | list: listBookmarks, 17 | create: createBookmark, 18 | update: updateBookmark, 19 | delete: deleteBookmark, 20 | refetch: refetchBookmark, 21 | }, 22 | group: { 23 | list: listGroups, 24 | create: createGroup, 25 | update: updateGroup, 26 | delete: deleteGroup, 27 | }, 28 | }); 29 | 30 | export type Router = typeof router; 31 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Label as LabelPrimitive } from "radix-ui" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Label({ 9 | className, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 21 | ) 22 | } 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | 43 | # prisma 44 | prisma/generated 45 | 46 | # sqlite 47 | *.db -------------------------------------------------------------------------------- /app/rpc/[[...rest]]/route.ts: -------------------------------------------------------------------------------- 1 | import { RPCHandler } from "@orpc/server/fetch"; 2 | import { onError } from "@orpc/server"; 3 | import { router } from "@/server"; 4 | 5 | const handler = new RPCHandler(router, { 6 | interceptors: [ 7 | onError((error: unknown) => { 8 | console.error(error); 9 | }), 10 | ], 11 | }); 12 | 13 | async function handleRequest(request: Request) { 14 | const { response } = await handler.handle(request, { 15 | prefix: "/rpc", 16 | }); 17 | 18 | return response ?? new Response("Not found", { status: 404 }); 19 | } 20 | 21 | export const GET = handleRequest; 22 | export const POST = handleRequest; 23 | export const PUT = handleRequest; 24 | export const PATCH = handleRequest; 25 | export const DELETE = handleRequest; 26 | -------------------------------------------------------------------------------- /server/context.ts: -------------------------------------------------------------------------------- 1 | import { ORPCError, os } from "@orpc/server"; 2 | import { headers } from "next/headers"; 3 | import { auth } from "@/lib/auth"; 4 | import type { Session } from "@/lib/schema"; 5 | 6 | export const base = os.use(async ({ next }) => { 7 | const headersList = await headers(); 8 | const session = (await auth.api.getSession({ 9 | headers: headersList, 10 | })) as Session; 11 | 12 | return next({ 13 | context: { 14 | session, 15 | user: session?.user ?? null, 16 | }, 17 | }); 18 | }); 19 | 20 | export const authed = base.use(({ context, next }) => { 21 | if (!context.user) { 22 | throw new ORPCError("UNAUTHORIZED"); 23 | } 24 | 25 | return next({ 26 | context: { 27 | ...context, 28 | user: context.user, 29 | }, 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /components/header-skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | 3 | export function HeaderSkeleton() { 4 | return ( 5 |
6 |
7 | 8 | / 9 |
10 | 11 | 12 |
13 |
14 | 15 |
16 | 17 | 18 |
19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "react-jsx", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": [ 26 | "next-env.d.ts", 27 | "**/*.ts", 28 | "**/*.tsx", 29 | ".next/types/**/*.ts", 30 | ".next/dev/types/**/*.ts", 31 | "**/*.mts" 32 | ], 33 | "exclude": ["node_modules"] 34 | } 35 | -------------------------------------------------------------------------------- /components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Separator as SeparatorPrimitive } from "radix-ui" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | function Separator({ 9 | className, 10 | orientation = "horizontal", 11 | decorative = true, 12 | ...props 13 | }: React.ComponentProps) { 14 | return ( 15 | 25 | ) 26 | } 27 | 28 | export { Separator } 29 | -------------------------------------------------------------------------------- /lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth } from "better-auth"; 2 | import { createAuthMiddleware } from "better-auth/api"; 3 | import { prismaAdapter } from "better-auth/adapters/prisma"; 4 | import { db } from "./db"; 5 | 6 | export const auth = betterAuth({ 7 | secret: process.env.BETTER_AUTH_SECRET, 8 | database: prismaAdapter(db, { 9 | provider: "sqlite", 10 | }), 11 | emailAndPassword: { 12 | enabled: true, 13 | }, 14 | hooks: { 15 | after: createAuthMiddleware(async (ctx) => { 16 | if (ctx.path.startsWith("/sign-up") && ctx.context.newSession) { 17 | await db.group.create({ 18 | data: { 19 | name: "Bookmarks", 20 | color: "#74B06F", 21 | userId: ctx.context.newSession.user.id, 22 | }, 23 | }); 24 | } 25 | }), 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | function Textarea({ className, ...props }: React.ComponentProps<"textarea">) { 6 | return ( 7 |