├── app ├── robots.txt ├── favicon.ico ├── fonts │ ├── GeistVF.woff │ └── GeistMonoVF.woff ├── opengraph-image.jpg ├── twitter-image.jpg ├── api │ ├── admin │ │ ├── login │ │ │ └── route.ts │ │ └── logout │ │ │ └── route.ts │ ├── bookmarks │ │ ├── [url] │ │ │ └── route.ts │ │ └── route.ts │ ├── subscribe │ │ └── route.ts │ ├── generate │ │ └── route.ts │ └── metadata │ │ └── route.ts ├── sitemap.ts ├── globals.css ├── layout.tsx ├── page.tsx ├── admin │ ├── login │ │ └── page.tsx │ ├── page.tsx │ ├── basic │ │ └── page.tsx │ └── manage │ │ └── page.tsx └── [slug] │ └── page.tsx ├── .prettierrc ├── twitter-image.jpg ├── middleware.ts ├── public ├── placeholder.jpg └── logo.svg ├── drizzle ├── 0006_rename_excerpt_to_overview.sql └── 0005_rename_excerpt_add_search.sql ├── migrations ├── 0001_wet_maelstrom.sql ├── meta │ ├── _journal.json │ ├── 0000_snapshot.json │ ├── 0001_snapshot.json │ └── 0002_snapshot.json ├── 0000_bouncy_turbo.sql └── 0002_furry_mindworm.sql ├── postcss.config.mjs ├── directory.config.ts ├── drizzle.config.ts ├── components ├── ui │ ├── skeleton.tsx │ ├── textarea.tsx │ ├── label.tsx │ ├── input.tsx │ ├── sonner.tsx │ ├── checkbox.tsx │ ├── badge.tsx │ ├── alert.tsx │ ├── button.tsx │ ├── tabs.tsx │ ├── card.tsx │ ├── table.tsx │ ├── dialog.tsx │ ├── sheet.tsx │ ├── form.tsx │ └── select.tsx ├── theme-provider.tsx ├── back-button.tsx ├── bookmark-grid.tsx ├── search-results-counter.tsx ├── theme-toggle.tsx ├── admin │ ├── result-display.tsx │ ├── admin-header.tsx │ ├── url-scraper.tsx │ └── category-manager.tsx ├── search-bar.tsx ├── email-form.tsx ├── craft.tsx ├── category-filter.tsx └── bookmark-card.tsx ├── lib ├── utils.ts ├── boho.ts ├── data.ts └── actions.ts ├── components.json ├── .gitignore ├── .env.example ├── db ├── client.ts ├── schema.ts └── seed.ts ├── tsconfig.json ├── LICENSE ├── next.config.mjs ├── tailwind.config.ts ├── package.json └── README.md /app/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/9d8dev/directory/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "prettier-plugin-tailwindcss" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /twitter-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/9d8dev/directory/HEAD/twitter-image.jpg -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { boho } from "@/lib/boho"; 2 | 3 | export default boho.middleware; 4 | -------------------------------------------------------------------------------- /app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/9d8dev/directory/HEAD/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /app/opengraph-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/9d8dev/directory/HEAD/app/opengraph-image.jpg -------------------------------------------------------------------------------- /app/twitter-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/9d8dev/directory/HEAD/app/twitter-image.jpg -------------------------------------------------------------------------------- /public/placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/9d8dev/directory/HEAD/public/placeholder.jpg -------------------------------------------------------------------------------- /app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/9d8dev/directory/HEAD/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /drizzle/0006_rename_excerpt_to_overview.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE bookmarks RENAME COLUMN excerpt TO overview; 2 | -------------------------------------------------------------------------------- /app/api/admin/login/route.ts: -------------------------------------------------------------------------------- 1 | import { boho } from "@/lib/boho"; 2 | 3 | export const { POST } = boho.handlers; 4 | -------------------------------------------------------------------------------- /migrations/0001_wet_maelstrom.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `bookmarks` ADD `slug` text NOT NULL;--> statement-breakpoint 2 | CREATE UNIQUE INDEX `bookmarks_slug_unique` ON `bookmarks` (`slug`); -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /app/api/admin/logout/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { cookies } from "next/headers"; 3 | 4 | export async function GET() { 5 | // Remove the authentication cookie 6 | cookies().delete("boho_token"); 7 | 8 | return NextResponse.redirect(new URL("/", process.env.NEXT_PUBLIC_SITE_URL)); 9 | } 10 | -------------------------------------------------------------------------------- /directory.config.ts: -------------------------------------------------------------------------------- 1 | export const directory = { 2 | baseUrl: "https://directory.9d8.dev", 3 | name: "9d8/directory", 4 | title: "9d8/directory | AI-powered Next.js Directory Template by 9d8", 5 | description: 6 | "Use this template to display a collection of resources or bookmarks. Create a directory website in little to no time.", 7 | }; 8 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "drizzle-kit"; 2 | 3 | export default { 4 | schema: "./db/schema.ts", 5 | out: "./migrations", 6 | dialect: "sqlite", 7 | driver: "turso", 8 | dbCredentials: { 9 | url: process.env.TURSO_DATABASE_URL!, 10 | authToken: process.env.TURSO_AUTH_TOKEN, 11 | }, 12 | } satisfies Config; 13 | -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /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 generateSlug(title: string): string { 9 | return title 10 | .toLowerCase() 11 | .replace(/[^a-z0-9]+/g, "-") 12 | .replace(/^-+|-+$/g, ""); 13 | } 14 | -------------------------------------------------------------------------------- /components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import { ThemeProvider as NextThemesProvider } from "next-themes"; 5 | import { type ThemeProviderProps } from "next-themes/dist/types"; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /components/back-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | import { Button } from "@/components/ui/button"; 5 | 6 | export const BackButton = () => { 7 | const router = useRouter(); 8 | 9 | return ( 10 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /lib/boho.ts: -------------------------------------------------------------------------------- 1 | import { bohoAuth } from "bohoauth"; 2 | 3 | export const boho = bohoAuth({ 4 | password: process.env.BOHO_PASSWORD!, 5 | secret: process.env.BOHO_SECRET!, 6 | expiresIn: "1h", 7 | middleware: { 8 | loginPath: "/admin/login", 9 | protectedPaths: [ 10 | "/admin", 11 | "/admin/basic", 12 | "/admin/manage", 13 | "/api/bookmarks", 14 | "/api/generate", 15 | "/api/metadata", 16 | ], 17 | redirectPath: "/admin", 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /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": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /components/bookmark-grid.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export const BookmarkGrid = ({ children }: { children: React.ReactNode }) => { 4 | return ( 5 |
6 | {React.Children.map(children, (child, index) => ( 7 |
12 | {child} 13 |
14 | ))} 15 |
16 | ); 17 | }; 18 | -------------------------------------------------------------------------------- /.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.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Database credentials 2 | TURSO_DATABASE_URL=your_turso_database_url 3 | TURSO_AUTH_TOKEN=your_turso_auth_token 4 | 5 | # Loops API key (for email subscription) 6 | LOOPS_API_KEY=your_loops_api_key 7 | 8 | # Admin password for /admin routes 9 | ADMIN_PASSWORD=your_admin_password 10 | 11 | # JWT Secret - run $ openssl rand -hex 16 to generate one 12 | JWT_SECRET=secret 13 | 14 | # Claude 15 | ANTHROPIC_API_KEY=your_anthropic_api_key 16 | 17 | # Exa for search 18 | EXASEARCH_API_KEY=your_exasearch_api_key 19 | 20 | # Site URL 21 | NEXT_PUBLIC_SITE_URL=your_site_url 22 | -------------------------------------------------------------------------------- /migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "6", 8 | "when": 1731609975821, 9 | "tag": "0000_bouncy_turbo", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "6", 15 | "when": 1731612182019, 16 | "tag": "0001_wet_maelstrom", 17 | "breakpoints": true 18 | }, 19 | { 20 | "idx": 2, 21 | "version": "6", 22 | "when": 1731613988197, 23 | "tag": "0002_furry_mindworm", 24 | "breakpoints": true 25 | } 26 | ] 27 | } -------------------------------------------------------------------------------- /db/client.ts: -------------------------------------------------------------------------------- 1 | import { drizzle } from "drizzle-orm/libsql"; 2 | import { createClient } from "@libsql/client"; 3 | import * as schema from "./schema"; 4 | import "dotenv/config"; 5 | 6 | if (!process.env.TURSO_DATABASE_URL) { 7 | throw new Error("TURSO_DATABASE_URL environment variable is not set"); 8 | } 9 | 10 | if (!process.env.TURSO_AUTH_TOKEN) { 11 | throw new Error("TURSO_AUTH_TOKEN environment variable is not set"); 12 | } 13 | 14 | const turso = createClient({ 15 | url: process.env.TURSO_DATABASE_URL, 16 | authToken: process.env.TURSO_AUTH_TOKEN, 17 | }); 18 | 19 | export const db = drizzle(turso, { schema }); 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /components/search-results-counter.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useSearchParams } from "next/navigation"; 4 | 5 | interface SearchResultsCounterProps { 6 | totalResults: number; 7 | } 8 | 9 | export function SearchResultsCounter({ 10 | totalResults, 11 | }: SearchResultsCounterProps) { 12 | const searchParams = useSearchParams(); 13 | const searchTerm = searchParams.get("search"); 14 | const category = searchParams.get("category"); 15 | 16 | return ( 17 |
18 | Found {totalResults} resources{totalResults !== 1 ? "s" : ""} 19 | {searchTerm && ` matching "${searchTerm}"`} 20 | {category && ` in category "${category}"`} 21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Textarea = React.forwardRef< 6 | HTMLTextAreaElement, 7 | React.ComponentProps<"textarea"> 8 | >(({ className, ...props }, ref) => { 9 | return ( 10 |