A personal media tracker built on the AT Protocol opnshelf.xyz
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: update project configuration and add search functionality for movies

+769 -745
+85
README.md
··· 1 + # OpnShelf 2 + 3 + A personal media tracker built on the AT Protocol. Track movies you've watched and discover what others are watching - all while owning your data. 4 + 5 + ## Tech Stack 6 + 7 + - **Backend**: NestJS + OpenAPI + PostgreSQL 8 + - **Web**: TanStack Start 9 + - **Mobile**: Expo / React Native 10 + - **Protocol**: AT Protocol (decentralized data storage) 11 + - **Monorepo**: pnpm workspaces + Turbo 12 + 13 + ## Project Structure 14 + 15 + ``` 16 + opnshelf/ 17 + ├── apps/ 18 + │ ├── web/ # TanStack Start web app 19 + │ └── mobile/ # Expo mobile app 20 + ├── packages/ 21 + │ ├── api/ # Shared API client (OpenAPI generated types) 22 + │ └── types/ # Shared TypeScript types 23 + └── backend/ # NestJS API + Firehose indexer 24 + ``` 25 + 26 + ## Getting Started 27 + 28 + ### Prerequisites 29 + 30 + - Node.js 18+ 31 + - pnpm 32 + - PostgreSQL (Railway recommended) 33 + 34 + ### Setup 35 + 36 + 1. Clone and install dependencies: 37 + ```bash 38 + pnpm install 39 + ``` 40 + 41 + 2. Configure environment variables: 42 + ```bash 43 + # backend/.env 44 + DATABASE_URL="postgresql://..." 45 + TMDB_API_KEY="..." 46 + ``` 47 + 48 + 3. Run database migrations: 49 + ```bash 50 + pnpm prisma:migrate 51 + ``` 52 + 53 + 4. Start development servers: 54 + ```bash 55 + # All services 56 + pnpm dev 57 + 58 + # Or individually 59 + pnpm dev:backend 60 + pnpm dev:web 61 + pnpm dev:mobile 62 + ``` 63 + 64 + ### Generate API Types 65 + 66 + After backend changes: 67 + ```bash 68 + pnpm generate:api 69 + ``` 70 + 71 + ## MVP Features 72 + 73 + - Movie search (TMDB) 74 + - Track watched movies (stored in AT Protocol) 75 + - Browse trending/popular movies (no login required) 76 + - AT Protocol OAuth authentication 77 + - Dark mode with Material You inspired design 78 + 79 + ## Architecture 80 + 81 + Users track movies which are stored as AT Protocol records in their personal data repository. The backend subscribes to the AT Protocol firehose to index public records, enabling discovery and social features while users maintain ownership of their data. 82 + 83 + ## License 84 + 85 + MIT
+64 -142
apps/web/src/components/Header.tsx
··· 1 1 import { Link } from '@tanstack/react-router' 2 - 3 2 import { useState } from 'react' 4 - import { 5 - ChevronDown, 6 - ChevronRight, 7 - Home, 8 - Menu, 9 - Network, 10 - SquareFunction, 11 - StickyNote, 12 - X, 13 - } from 'lucide-react' 3 + import { Film, Home, Menu, Search, X } from 'lucide-react' 14 4 15 5 export default function Header() { 16 6 const [isOpen, setIsOpen] = useState(false) 17 - const [groupedExpanded, setGroupedExpanded] = useState< 18 - Record<string, boolean> 19 - >({}) 20 7 21 8 return ( 22 9 <> 23 - <header className="p-4 flex items-center bg-gray-800 text-white shadow-lg"> 24 - <button 25 - onClick={() => setIsOpen(true)} 26 - className="p-2 hover:bg-gray-700 rounded-lg transition-colors" 27 - aria-label="Open menu" 28 - > 29 - <Menu size={24} /> 30 - </button> 31 - <h1 className="ml-4 text-xl font-semibold"> 32 - <Link to="/"> 33 - <img 34 - src="/tanstack-word-logo-white.svg" 35 - alt="TanStack Logo" 36 - className="h-10" 37 - /> 38 - </Link> 39 - </h1> 40 - </header> 41 - 42 - <aside 43 - className={`fixed top-0 left-0 h-full w-80 bg-gray-900 text-white shadow-2xl z-50 transform transition-transform duration-300 ease-in-out flex flex-col ${ 44 - isOpen ? 'translate-x-0' : '-translate-x-full' 45 - }`} 46 - > 47 - <div className="flex items-center justify-between p-4 border-b border-gray-700"> 48 - <h2 className="text-xl font-bold">Navigation</h2> 10 + <header className="px-4 py-3 flex items-center justify-between bg-gray-900 text-white border-b border-gray-800"> 11 + <div className="flex items-center gap-3"> 49 12 <button 50 - onClick={() => setIsOpen(false)} 51 - className="p-2 hover:bg-gray-800 rounded-lg transition-colors" 52 - aria-label="Close menu" 13 + onClick={() => setIsOpen(true)} 14 + className="p-2 hover:bg-gray-800 rounded-lg transition-colors md:hidden" 15 + aria-label="Open menu" 53 16 > 54 - <X size={24} /> 17 + <Menu size={24} /> 55 18 </button> 19 + <Link to="/" className="flex items-center gap-2"> 20 + <Film className="w-8 h-8 text-purple-500" /> 21 + <span className="text-xl font-bold">OpnShelf</span> 22 + </Link> 56 23 </div> 57 24 58 - <nav className="flex-1 p-4 overflow-y-auto"> 25 + {/* Desktop nav */} 26 + <nav className="hidden md:flex items-center gap-1"> 59 27 <Link 60 28 to="/" 61 - onClick={() => setIsOpen(false)} 62 - className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" 29 + className="flex items-center gap-2 px-4 py-2 rounded-lg hover:bg-gray-800 transition-colors text-gray-300 hover:text-white" 63 30 activeProps={{ 64 31 className: 65 - 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', 32 + 'flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-600 hover:bg-purple-700 transition-colors text-white', 66 33 }} 34 + activeOptions={{ exact: true }} 67 35 > 68 - <Home size={20} /> 36 + <Home size={18} /> 69 37 <span className="font-medium">Home</span> 70 38 </Link> 71 - 72 - {/* Demo Links Start */} 73 - 74 39 <Link 75 - to="/demo/start/server-funcs" 76 - onClick={() => setIsOpen(false)} 77 - className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" 40 + to="/search" 41 + className="flex items-center gap-2 px-4 py-2 rounded-lg hover:bg-gray-800 transition-colors text-gray-300 hover:text-white" 78 42 activeProps={{ 79 43 className: 80 - 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', 44 + 'flex items-center gap-2 px-4 py-2 rounded-lg bg-purple-600 hover:bg-purple-700 transition-colors text-white', 81 45 }} 82 46 > 83 - <SquareFunction size={20} /> 84 - <span className="font-medium">Start - Server Functions</span> 47 + <Search size={18} /> 48 + <span className="font-medium">Search</span> 85 49 </Link> 50 + </nav> 51 + </header> 86 52 53 + {/* Mobile drawer overlay */} 54 + {isOpen && ( 55 + <div 56 + className="fixed inset-0 bg-black/50 z-40 md:hidden" 57 + onClick={() => setIsOpen(false)} 58 + /> 59 + )} 60 + 61 + {/* Mobile drawer */} 62 + <aside 63 + className={`fixed top-0 left-0 h-full w-72 bg-gray-900 text-white shadow-2xl z-50 transform transition-transform duration-300 ease-in-out flex flex-col md:hidden ${ 64 + isOpen ? 'translate-x-0' : '-translate-x-full' 65 + }`} 66 + > 67 + <div className="flex items-center justify-between p-4 border-b border-gray-800"> 68 + <div className="flex items-center gap-2"> 69 + <Film className="w-6 h-6 text-purple-500" /> 70 + <span className="text-lg font-bold">OpnShelf</span> 71 + </div> 72 + <button 73 + onClick={() => setIsOpen(false)} 74 + className="p-2 hover:bg-gray-800 rounded-lg transition-colors" 75 + aria-label="Close menu" 76 + > 77 + <X size={24} /> 78 + </button> 79 + </div> 80 + 81 + <nav className="flex-1 p-4 overflow-y-auto"> 87 82 <Link 88 - to="/demo/start/api-request" 83 + to="/" 89 84 onClick={() => setIsOpen(false)} 90 - className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" 85 + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2 text-gray-300 hover:text-white" 91 86 activeProps={{ 92 87 className: 93 - 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', 88 + 'flex items-center gap-3 p-3 rounded-lg bg-purple-600 hover:bg-purple-700 transition-colors mb-2 text-white', 94 89 }} 90 + activeOptions={{ exact: true }} 95 91 > 96 - <Network size={20} /> 97 - <span className="font-medium">Start - API Request</span> 92 + <Home size={20} /> 93 + <span className="font-medium">Home</span> 98 94 </Link> 99 95 100 - <div className="flex flex-row justify-between"> 101 - <Link 102 - to="/demo/start/ssr" 103 - onClick={() => setIsOpen(false)} 104 - className="flex-1 flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" 105 - activeProps={{ 106 - className: 107 - 'flex-1 flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', 108 - }} 109 - > 110 - <StickyNote size={20} /> 111 - <span className="font-medium">Start - SSR Demos</span> 112 - </Link> 113 - <button 114 - className="p-2 hover:bg-gray-800 rounded-lg transition-colors" 115 - onClick={() => 116 - setGroupedExpanded((prev) => ({ 117 - ...prev, 118 - StartSSRDemo: !prev.StartSSRDemo, 119 - })) 120 - } 121 - > 122 - {groupedExpanded.StartSSRDemo ? ( 123 - <ChevronDown size={20} /> 124 - ) : ( 125 - <ChevronRight size={20} /> 126 - )} 127 - </button> 128 - </div> 129 - {groupedExpanded.StartSSRDemo && ( 130 - <div className="flex flex-col ml-4"> 131 - <Link 132 - to="/demo/start/ssr/spa-mode" 133 - onClick={() => setIsOpen(false)} 134 - className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" 135 - activeProps={{ 136 - className: 137 - 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', 138 - }} 139 - > 140 - <StickyNote size={20} /> 141 - <span className="font-medium">SPA Mode</span> 142 - </Link> 143 - 144 - <Link 145 - to="/demo/start/ssr/full-ssr" 146 - onClick={() => setIsOpen(false)} 147 - className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" 148 - activeProps={{ 149 - className: 150 - 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', 151 - }} 152 - > 153 - <StickyNote size={20} /> 154 - <span className="font-medium">Full SSR</span> 155 - </Link> 156 - 157 - <Link 158 - to="/demo/start/ssr/data-only" 159 - onClick={() => setIsOpen(false)} 160 - className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" 161 - activeProps={{ 162 - className: 163 - 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', 164 - }} 165 - > 166 - <StickyNote size={20} /> 167 - <span className="font-medium">Data Only</span> 168 - </Link> 169 - </div> 170 - )} 171 - 172 96 <Link 173 - to="/demo/tanstack-query" 97 + to="/search" 174 98 onClick={() => setIsOpen(false)} 175 - className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" 99 + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2 text-gray-300 hover:text-white" 176 100 activeProps={{ 177 101 className: 178 - 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', 102 + 'flex items-center gap-3 p-3 rounded-lg bg-purple-600 hover:bg-purple-700 transition-colors mb-2 text-white', 179 103 }} 180 104 > 181 - <Network size={20} /> 182 - <span className="font-medium">TanStack Query</span> 105 + <Search size={20} /> 106 + <span className="font-medium">Search</span> 183 107 </Link> 184 - 185 - {/* Demo Links End */} 186 108 </nav> 187 109 </aside> 188 110 </>
+10
apps/web/src/lib/env.ts
··· 1 + import { createEnv } from '@t3-oss/env-core'; 2 + import { z } from 'zod'; 3 + 4 + export const env = createEnv({ 5 + clientPrefix: 'VITE_', 6 + client: { 7 + VITE_API_URL: z.string().url(), 8 + }, 9 + runtimeEnv: import.meta.env, 10 + });
+86
apps/web/src/routeTree.gen.ts
··· 1 + /* eslint-disable */ 2 + 3 + // @ts-nocheck 4 + 5 + // noinspection JSUnusedGlobalSymbols 6 + 7 + // This file was automatically generated by TanStack Router. 8 + // You should NOT make any changes in this file as it will be overwritten. 9 + // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. 10 + 11 + import { Route as rootRouteImport } from './routes/__root' 12 + import { Route as SearchRouteImport } from './routes/search' 13 + import { Route as IndexRouteImport } from './routes/index' 14 + 15 + const SearchRoute = SearchRouteImport.update({ 16 + id: '/search', 17 + path: '/search', 18 + getParentRoute: () => rootRouteImport, 19 + } as any) 20 + const IndexRoute = IndexRouteImport.update({ 21 + id: '/', 22 + path: '/', 23 + getParentRoute: () => rootRouteImport, 24 + } as any) 25 + 26 + export interface FileRoutesByFullPath { 27 + '/': typeof IndexRoute 28 + '/search': typeof SearchRoute 29 + } 30 + export interface FileRoutesByTo { 31 + '/': typeof IndexRoute 32 + '/search': typeof SearchRoute 33 + } 34 + export interface FileRoutesById { 35 + __root__: typeof rootRouteImport 36 + '/': typeof IndexRoute 37 + '/search': typeof SearchRoute 38 + } 39 + export interface FileRouteTypes { 40 + fileRoutesByFullPath: FileRoutesByFullPath 41 + fullPaths: '/' | '/search' 42 + fileRoutesByTo: FileRoutesByTo 43 + to: '/' | '/search' 44 + id: '__root__' | '/' | '/search' 45 + fileRoutesById: FileRoutesById 46 + } 47 + export interface RootRouteChildren { 48 + IndexRoute: typeof IndexRoute 49 + SearchRoute: typeof SearchRoute 50 + } 51 + 52 + declare module '@tanstack/react-router' { 53 + interface FileRoutesByPath { 54 + '/search': { 55 + id: '/search' 56 + path: '/search' 57 + fullPath: '/search' 58 + preLoaderRoute: typeof SearchRouteImport 59 + parentRoute: typeof rootRouteImport 60 + } 61 + '/': { 62 + id: '/' 63 + path: '/' 64 + fullPath: '/' 65 + preLoaderRoute: typeof IndexRouteImport 66 + parentRoute: typeof rootRouteImport 67 + } 68 + } 69 + } 70 + 71 + const rootRouteChildren: RootRouteChildren = { 72 + IndexRoute: IndexRoute, 73 + SearchRoute: SearchRoute, 74 + } 75 + export const routeTree = rootRouteImport 76 + ._addFileChildren(rootRouteChildren) 77 + ._addFileTypes<FileRouteTypes>() 78 + 79 + import type { getRouter } from './router.tsx' 80 + import type { createStart } from '@tanstack/react-start' 81 + declare module '@tanstack/react-start' { 82 + interface Register { 83 + ssr: true 84 + router: Awaited<ReturnType<typeof getRouter>> 85 + } 86 + }
+23 -17
apps/web/src/router.tsx
··· 1 - import { createRouter } from '@tanstack/react-router' 2 - import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query' 3 - import * as TanstackQuery from './integrations/tanstack-query/root-provider' 1 + import { QueryClient } from '@tanstack/react-query'; 2 + import { createRouter as createTanStackRouter } from '@tanstack/react-router'; 3 + import { configureApiClient } from '@opnshelf/api'; 4 + import { env } from './lib/env'; 4 5 5 - // Import the generated route tree 6 - import { routeTree } from './routeTree.gen' 6 + import { routeTree } from './routeTree.gen'; 7 7 8 - // Create a new router instance 9 - export const getRouter = () => { 10 - const rqContext = TanstackQuery.getContext() 8 + configureApiClient(env.VITE_API_URL); 11 9 12 - const router = createRouter({ 13 - routeTree, 14 - context: { 15 - ...rqContext, 10 + const queryClient = new QueryClient({ 11 + defaultOptions: { 12 + queries: { 13 + staleTime: 60 * 1000, // 1 minute 16 14 }, 15 + }, 16 + }); 17 17 18 + export function getRouter() { 19 + return createTanStackRouter({ 20 + routeTree, 21 + context: { queryClient }, 18 22 defaultPreload: 'intent', 19 - }) 20 - 21 - setupRouterSsrQueryIntegration({ router, queryClient: rqContext.queryClient }) 23 + }); 24 + } 22 25 23 - return router 24 - } 26 + declare module '@tanstack/react-router' { 27 + interface Register { 28 + router: ReturnType<typeof getRouter>; 29 + } 30 + }
+26 -16
apps/web/src/routes/__root.tsx
··· 2 2 HeadContent, 3 3 Scripts, 4 4 createRootRouteWithContext, 5 + Outlet, 5 6 } from '@tanstack/react-router' 7 + import { QueryClientProvider, type QueryClient } from '@tanstack/react-query' 6 8 import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools' 7 9 import { TanStackDevtools } from '@tanstack/react-devtools' 8 10 ··· 11 13 import TanStackQueryDevtools from '../integrations/tanstack-query/devtools' 12 14 13 15 import appCss from '../styles.css?url' 14 - 15 - import type { QueryClient } from '@tanstack/react-query' 16 16 17 17 interface MyRouterContext { 18 18 queryClient: QueryClient ··· 29 29 content: 'width=device-width, initial-scale=1', 30 30 }, 31 31 { 32 - title: 'TanStack Start Starter', 32 + title: 'OpnShelf', 33 33 }, 34 34 ], 35 35 links: [ ··· 40 40 ], 41 41 }), 42 42 43 + component: RootComponent, 43 44 shellComponent: RootDocument, 44 45 }) 45 46 47 + function RootComponent() { 48 + const { queryClient } = Route.useRouteContext() 49 + return ( 50 + <QueryClientProvider client={queryClient}> 51 + <Header /> 52 + <Outlet /> 53 + <TanStackDevtools 54 + config={{ 55 + position: 'bottom-right', 56 + }} 57 + plugins={[ 58 + { 59 + name: 'Tanstack Router', 60 + render: <TanStackRouterDevtoolsPanel />, 61 + }, 62 + TanStackQueryDevtools, 63 + ]} 64 + /> 65 + </QueryClientProvider> 66 + ) 67 + } 68 + 46 69 function RootDocument({ children }: { children: React.ReactNode }) { 47 70 return ( 48 71 <html lang="en"> ··· 50 73 <HeadContent /> 51 74 </head> 52 75 <body> 53 - <Header /> 54 76 {children} 55 - <TanStackDevtools 56 - config={{ 57 - position: 'bottom-right', 58 - }} 59 - plugins={[ 60 - { 61 - name: 'Tanstack Router', 62 - render: <TanStackRouterDevtoolsPanel />, 63 - }, 64 - TanStackQueryDevtools, 65 - ]} 66 - /> 67 77 <Scripts /> 68 78 </body> 69 79 </html>
-10
apps/web/src/routes/demo/api.names.ts
··· 1 - import { createFileRoute } from '@tanstack/react-router' 2 - import { json } from '@tanstack/react-start' 3 - 4 - export const Route = createFileRoute('/demo/api/names')({ 5 - server: { 6 - handlers: { 7 - GET: () => json(['Alice', 'Bob', 'Charlie']), 8 - }, 9 - }, 10 - })
-35
apps/web/src/routes/demo/api.tq-todos.ts
··· 1 - import { createFileRoute } from '@tanstack/react-router' 2 - 3 - const todos = [ 4 - { 5 - id: 1, 6 - name: 'Buy groceries', 7 - }, 8 - { 9 - id: 2, 10 - name: 'Buy mobile phone', 11 - }, 12 - { 13 - id: 3, 14 - name: 'Buy laptop', 15 - }, 16 - ] 17 - 18 - export const Route = createFileRoute('/demo/api/tq-todos')({ 19 - server: { 20 - handlers: { 21 - GET: () => { 22 - return Response.json(todos) 23 - }, 24 - POST: async ({ request }) => { 25 - const name = await request.json() 26 - const todo = { 27 - id: todos.length + 1, 28 - name, 29 - } 30 - todos.push(todo) 31 - return Response.json(todo) 32 - }, 33 - }, 34 - }, 35 - })
-43
apps/web/src/routes/demo/start.api-request.tsx
··· 1 - import { useQuery } from '@tanstack/react-query' 2 - 3 - import { createFileRoute } from '@tanstack/react-router' 4 - 5 - function getNames() { 6 - return fetch('/demo/api/names').then((res) => res.json() as Promise<string[]>) 7 - } 8 - 9 - export const Route = createFileRoute('/demo/start/api-request')({ 10 - component: Home, 11 - }) 12 - 13 - function Home() { 14 - const { data: names = [] } = useQuery({ 15 - queryKey: ['names'], 16 - queryFn: getNames, 17 - }) 18 - 19 - return ( 20 - <div 21 - className="flex items-center justify-center min-h-screen p-4 text-white" 22 - style={{ 23 - backgroundColor: '#000', 24 - backgroundImage: 25 - 'radial-gradient(ellipse 60% 60% at 0% 100%, #444 0%, #222 60%, #000 100%)', 26 - }} 27 - > 28 - <div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10"> 29 - <h1 className="text-2xl mb-4">Start API Request Demo - Names List</h1> 30 - <ul className="mb-4 space-y-2"> 31 - {names.map((name) => ( 32 - <li 33 - key={name} 34 - className="bg-white/10 border border-white/20 rounded-lg p-3 backdrop-blur-sm shadow-md" 35 - > 36 - <span className="text-lg text-white">{name}</span> 37 - </li> 38 - ))} 39 - </ul> 40 - </div> 41 - </div> 42 - ) 43 - }
-109
apps/web/src/routes/demo/start.server-funcs.tsx
··· 1 - import fs from 'node:fs' 2 - import { useCallback, useState } from 'react' 3 - import { createFileRoute, useRouter } from '@tanstack/react-router' 4 - import { createServerFn } from '@tanstack/react-start' 5 - 6 - /* 7 - const loggingMiddleware = createMiddleware().server( 8 - async ({ next, request }) => { 9 - console.log("Request:", request.url); 10 - return next(); 11 - } 12 - ); 13 - const loggedServerFunction = createServerFn({ method: "GET" }).middleware([ 14 - loggingMiddleware, 15 - ]); 16 - */ 17 - 18 - const TODOS_FILE = 'todos.json' 19 - 20 - async function readTodos() { 21 - return JSON.parse( 22 - await fs.promises.readFile(TODOS_FILE, 'utf-8').catch(() => 23 - JSON.stringify( 24 - [ 25 - { id: 1, name: 'Get groceries' }, 26 - { id: 2, name: 'Buy a new phone' }, 27 - ], 28 - null, 29 - 2, 30 - ), 31 - ), 32 - ) 33 - } 34 - 35 - const getTodos = createServerFn({ 36 - method: 'GET', 37 - }).handler(async () => await readTodos()) 38 - 39 - const addTodo = createServerFn({ method: 'POST' }) 40 - .inputValidator((d: string) => d) 41 - .handler(async ({ data }) => { 42 - const todos = await readTodos() 43 - todos.push({ id: todos.length + 1, name: data }) 44 - await fs.promises.writeFile(TODOS_FILE, JSON.stringify(todos, null, 2)) 45 - return todos 46 - }) 47 - 48 - export const Route = createFileRoute('/demo/start/server-funcs')({ 49 - component: Home, 50 - loader: async () => await getTodos(), 51 - }) 52 - 53 - function Home() { 54 - const router = useRouter() 55 - let todos = Route.useLoaderData() 56 - 57 - const [todo, setTodo] = useState('') 58 - 59 - const submitTodo = useCallback(async () => { 60 - todos = await addTodo({ data: todo }) 61 - setTodo('') 62 - router.invalidate() 63 - }, [addTodo, todo]) 64 - 65 - return ( 66 - <div 67 - className="flex items-center justify-center min-h-screen bg-gradient-to-br from-zinc-800 to-black p-4 text-white" 68 - style={{ 69 - backgroundImage: 70 - 'radial-gradient(50% 50% at 20% 60%, #23272a 0%, #18181b 50%, #000000 100%)', 71 - }} 72 - > 73 - <div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10"> 74 - <h1 className="text-2xl mb-4">Start Server Functions - Todo Example</h1> 75 - <ul className="mb-4 space-y-2"> 76 - {todos?.map((t) => ( 77 - <li 78 - key={t.id} 79 - className="bg-white/10 border border-white/20 rounded-lg p-3 backdrop-blur-sm shadow-md" 80 - > 81 - <span className="text-lg text-white">{t.name}</span> 82 - </li> 83 - ))} 84 - </ul> 85 - <div className="flex flex-col gap-2"> 86 - <input 87 - type="text" 88 - value={todo} 89 - onChange={(e) => setTodo(e.target.value)} 90 - onKeyDown={(e) => { 91 - if (e.key === 'Enter') { 92 - submitTodo() 93 - } 94 - }} 95 - placeholder="Enter a new todo..." 96 - className="w-full px-4 py-3 rounded-lg border border-white/20 bg-white/10 backdrop-blur-sm text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent" 97 - /> 98 - <button 99 - disabled={todo.trim().length === 0} 100 - onClick={submitTodo} 101 - className="bg-blue-500 hover:bg-blue-600 disabled:bg-blue-500/50 disabled:cursor-not-allowed text-white font-bold py-3 px-4 rounded-lg transition-colors" 102 - > 103 - Add todo 104 - </button> 105 - </div> 106 - </div> 107 - </div> 108 - ) 109 - }
-41
apps/web/src/routes/demo/start.ssr.data-only.tsx
··· 1 - import { createFileRoute } from '@tanstack/react-router' 2 - import { getPunkSongs } from '@/data/demo.punk-songs' 3 - 4 - export const Route = createFileRoute('/demo/start/ssr/data-only')({ 5 - ssr: 'data-only', 6 - component: RouteComponent, 7 - loader: async () => await getPunkSongs(), 8 - }) 9 - 10 - function RouteComponent() { 11 - const punkSongs = Route.useLoaderData() 12 - 13 - return ( 14 - <div 15 - className="flex items-center justify-center min-h-screen bg-gradient-to-br from-zinc-800 to-black p-4 text-white" 16 - style={{ 17 - backgroundImage: 18 - 'radial-gradient(50% 50% at 20% 60%, #1a1a1a 0%, #0a0a0a 50%, #000000 100%)', 19 - }} 20 - > 21 - <div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10"> 22 - <h1 className="text-3xl font-bold mb-6 text-pink-400"> 23 - Data Only SSR - Punk Songs 24 - </h1> 25 - <ul className="space-y-3"> 26 - {punkSongs.map((song) => ( 27 - <li 28 - key={song.id} 29 - className="bg-white/10 border border-white/20 rounded-lg p-4 backdrop-blur-sm shadow-md" 30 - > 31 - <span className="text-lg text-white font-medium"> 32 - {song.name} 33 - </span> 34 - <span className="text-white/60"> - {song.artist}</span> 35 - </li> 36 - ))} 37 - </ul> 38 - </div> 39 - </div> 40 - ) 41 - }
-40
apps/web/src/routes/demo/start.ssr.full-ssr.tsx
··· 1 - import { createFileRoute } from '@tanstack/react-router' 2 - import { getPunkSongs } from '@/data/demo.punk-songs' 3 - 4 - export const Route = createFileRoute('/demo/start/ssr/full-ssr')({ 5 - component: RouteComponent, 6 - loader: async () => await getPunkSongs(), 7 - }) 8 - 9 - function RouteComponent() { 10 - const punkSongs = Route.useLoaderData() 11 - 12 - return ( 13 - <div 14 - className="flex items-center justify-center min-h-screen bg-gradient-to-br from-zinc-800 to-black p-4 text-white" 15 - style={{ 16 - backgroundImage: 17 - 'radial-gradient(50% 50% at 20% 60%, #1a1a1a 0%, #0a0a0a 50%, #000000 100%)', 18 - }} 19 - > 20 - <div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10"> 21 - <h1 className="text-3xl font-bold mb-6 text-purple-400"> 22 - Full SSR - Punk Songs 23 - </h1> 24 - <ul className="space-y-3"> 25 - {punkSongs.map((song) => ( 26 - <li 27 - key={song.id} 28 - className="bg-white/10 border border-white/20 rounded-lg p-4 backdrop-blur-sm shadow-md" 29 - > 30 - <span className="text-lg text-white font-medium"> 31 - {song.name} 32 - </span> 33 - <span className="text-white/60"> - {song.artist}</span> 34 - </li> 35 - ))} 36 - </ul> 37 - </div> 38 - </div> 39 - ) 40 - }
-43
apps/web/src/routes/demo/start.ssr.index.tsx
··· 1 - import { createFileRoute, Link } from '@tanstack/react-router' 2 - 3 - export const Route = createFileRoute('/demo/start/ssr/')({ 4 - component: RouteComponent, 5 - }) 6 - 7 - function RouteComponent() { 8 - return ( 9 - <div 10 - className="flex items-center justify-center min-h-screen bg-gradient-to-br from-zinc-900 to-black p-4 text-white" 11 - style={{ 12 - backgroundImage: 13 - 'radial-gradient(50% 50% at 20% 60%, #1a1a1a 0%, #0a0a0a 50%, #000000 100%)', 14 - }} 15 - > 16 - <div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10"> 17 - <h1 className="text-4xl font-bold mb-8 text-center bg-gradient-to-r from-pink-500 via-purple-500 to-green-400 bg-clip-text text-transparent"> 18 - SSR Demos 19 - </h1> 20 - <div className="flex flex-col gap-4"> 21 - <Link 22 - to="/demo/start/ssr/spa-mode" 23 - className="text-2xl font-bold py-6 px-8 rounded-lg bg-gradient-to-r from-pink-600 to-pink-500 hover:from-pink-700 hover:to-pink-600 text-white text-center shadow-lg transform transition-all hover:scale-105 hover:shadow-pink-500/50 border-2 border-pink-400" 24 - > 25 - SPA Mode 26 - </Link> 27 - <Link 28 - to="/demo/start/ssr/full-ssr" 29 - className="text-2xl font-bold py-6 px-8 rounded-lg bg-gradient-to-r from-purple-600 to-purple-500 hover:from-purple-700 hover:to-purple-600 text-white text-center shadow-lg transform transition-all hover:scale-105 hover:shadow-purple-500/50 border-2 border-purple-400" 30 - > 31 - Full SSR 32 - </Link> 33 - <Link 34 - to="/demo/start/ssr/data-only" 35 - className="text-2xl font-bold py-6 px-8 rounded-lg bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white text-center shadow-lg transform transition-all hover:scale-105 hover:shadow-green-500/50 border-2 border-green-400" 36 - > 37 - Data Only 38 - </Link> 39 - </div> 40 - </div> 41 - </div> 42 - ) 43 - }
-47
apps/web/src/routes/demo/start.ssr.spa-mode.tsx
··· 1 - import { useEffect, useState } from 'react' 2 - import { createFileRoute } from '@tanstack/react-router' 3 - import { getPunkSongs } from '@/data/demo.punk-songs' 4 - 5 - export const Route = createFileRoute('/demo/start/ssr/spa-mode')({ 6 - ssr: false, 7 - component: RouteComponent, 8 - }) 9 - 10 - function RouteComponent() { 11 - const [punkSongs, setPunkSongs] = useState< 12 - Awaited<ReturnType<typeof getPunkSongs>> 13 - >([]) 14 - 15 - useEffect(() => { 16 - getPunkSongs().then(setPunkSongs) 17 - }, []) 18 - 19 - return ( 20 - <div 21 - className="flex items-center justify-center min-h-screen bg-gradient-to-br from-zinc-800 to-black p-4 text-white" 22 - style={{ 23 - backgroundImage: 24 - 'radial-gradient(50% 50% at 20% 60%, #1a1a1a 0%, #0a0a0a 50%, #000000 100%)', 25 - }} 26 - > 27 - <div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10"> 28 - <h1 className="text-3xl font-bold mb-6 text-green-400"> 29 - SPA Mode - Punk Songs 30 - </h1> 31 - <ul className="space-y-3"> 32 - {punkSongs.map((song) => ( 33 - <li 34 - key={song.id} 35 - className="bg-white/10 border border-white/20 rounded-lg p-4 backdrop-blur-sm shadow-md" 36 - > 37 - <span className="text-lg text-white font-medium"> 38 - {song.name} 39 - </span> 40 - <span className="text-white/60"> - {song.artist}</span> 41 - </li> 42 - ))} 43 - </ul> 44 - </div> 45 - </div> 46 - ) 47 - }
-81
apps/web/src/routes/demo/tanstack-query.tsx
··· 1 - import { useCallback, useState } from 'react' 2 - import { createFileRoute } from '@tanstack/react-router' 3 - import { useQuery, useMutation } from '@tanstack/react-query' 4 - 5 - export const Route = createFileRoute('/demo/tanstack-query')({ 6 - component: TanStackQueryDemo, 7 - }) 8 - 9 - type Todo = { 10 - id: number 11 - name: string 12 - } 13 - 14 - function TanStackQueryDemo() { 15 - const { data, refetch } = useQuery<Todo[]>({ 16 - queryKey: ['todos'], 17 - queryFn: () => fetch('/demo/api/tq-todos').then((res) => res.json()), 18 - initialData: [], 19 - }) 20 - 21 - const { mutate: addTodo } = useMutation({ 22 - mutationFn: (todo: string) => 23 - fetch('/demo/api/tq-todos', { 24 - method: 'POST', 25 - body: JSON.stringify(todo), 26 - }).then((res) => res.json()), 27 - onSuccess: () => refetch(), 28 - }) 29 - 30 - const [todo, setTodo] = useState('') 31 - 32 - const submitTodo = useCallback(async () => { 33 - await addTodo(todo) 34 - setTodo('') 35 - }, [addTodo, todo]) 36 - 37 - return ( 38 - <div 39 - className="flex items-center justify-center min-h-screen bg-gradient-to-br from-red-900 via-red-800 to-black p-4 text-white" 40 - style={{ 41 - backgroundImage: 42 - 'radial-gradient(50% 50% at 80% 20%, #3B021F 0%, #7B1028 60%, #1A000A 100%)', 43 - }} 44 - > 45 - <div className="w-full max-w-2xl p-8 rounded-xl backdrop-blur-md bg-black/50 shadow-xl border-8 border-black/10"> 46 - <h1 className="text-2xl mb-4">TanStack Query Todos list</h1> 47 - <ul className="mb-4 space-y-2"> 48 - {data?.map((t) => ( 49 - <li 50 - key={t.id} 51 - className="bg-white/10 border border-white/20 rounded-lg p-3 backdrop-blur-sm shadow-md" 52 - > 53 - <span className="text-lg text-white">{t.name}</span> 54 - </li> 55 - ))} 56 - </ul> 57 - <div className="flex flex-col gap-2"> 58 - <input 59 - type="text" 60 - value={todo} 61 - onChange={(e) => setTodo(e.target.value)} 62 - onKeyDown={(e) => { 63 - if (e.key === 'Enter') { 64 - submitTodo() 65 - } 66 - }} 67 - placeholder="Enter a new todo..." 68 - className="w-full px-4 py-3 rounded-lg border border-white/20 bg-white/10 backdrop-blur-sm text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-blue-400 focus:border-transparent" 69 - /> 70 - <button 71 - disabled={todo.trim().length === 0} 72 - onClick={submitTodo} 73 - className="bg-blue-500 hover:bg-blue-600 disabled:bg-blue-500/50 disabled:cursor-not-allowed text-white font-bold py-3 px-4 rounded-lg transition-colors" 74 - > 75 - Add todo 76 - </button> 77 - </div> 78 - </div> 79 - </div> 80 - ) 81 - }
+43 -109
apps/web/src/routes/index.tsx
··· 1 - import { createFileRoute } from '@tanstack/react-router' 2 - import { 3 - Zap, 4 - Server, 5 - Route as RouteIcon, 6 - Shield, 7 - Waves, 8 - Sparkles, 9 - } from 'lucide-react' 1 + import { createFileRoute, Link } from '@tanstack/react-router'; 2 + import { Search, Film } from 'lucide-react'; 10 3 11 - export const Route = createFileRoute('/')({ component: App }) 12 - 13 - function App() { 14 - const features = [ 15 - { 16 - icon: <Zap className="w-12 h-12 text-cyan-400" />, 17 - title: 'Powerful Server Functions', 18 - description: 19 - 'Write server-side code that seamlessly integrates with your client components. Type-safe, secure, and simple.', 20 - }, 21 - { 22 - icon: <Server className="w-12 h-12 text-cyan-400" />, 23 - title: 'Flexible Server Side Rendering', 24 - description: 25 - 'Full-document SSR, streaming, and progressive enhancement out of the box. Control exactly what renders where.', 26 - }, 27 - { 28 - icon: <RouteIcon className="w-12 h-12 text-cyan-400" />, 29 - title: 'API Routes', 30 - description: 31 - 'Build type-safe API endpoints alongside your application. No separate backend needed.', 32 - }, 33 - { 34 - icon: <Shield className="w-12 h-12 text-cyan-400" />, 35 - title: 'Strongly Typed Everything', 36 - description: 37 - 'End-to-end type safety from server to client. Catch errors before they reach production.', 38 - }, 39 - { 40 - icon: <Waves className="w-12 h-12 text-cyan-400" />, 41 - title: 'Full Streaming Support', 42 - description: 43 - 'Stream data from server to client progressively. Perfect for AI applications and real-time updates.', 44 - }, 45 - { 46 - icon: <Sparkles className="w-12 h-12 text-cyan-400" />, 47 - title: 'Next Generation Ready', 48 - description: 49 - 'Built from the ground up for modern web applications. Deploy anywhere JavaScript runs.', 50 - }, 51 - ] 4 + export const Route = createFileRoute('/')({ 5 + component: HomePage, 6 + }); 52 7 8 + function HomePage() { 53 9 return ( 54 - <div className="min-h-screen bg-gradient-to-b from-slate-900 via-slate-800 to-slate-900"> 55 - <section className="relative py-20 px-6 text-center overflow-hidden"> 56 - <div className="absolute inset-0 bg-gradient-to-r from-cyan-500/10 via-blue-500/10 to-purple-500/10"></div> 57 - <div className="relative max-w-5xl mx-auto"> 58 - <div className="flex items-center justify-center gap-6 mb-6"> 59 - <img 60 - src="/tanstack-circle-logo.png" 61 - alt="TanStack Logo" 62 - className="w-24 h-24 md:w-32 md:h-32" 63 - /> 64 - <h1 className="text-6xl md:text-7xl font-black text-white [letter-spacing:-0.08em]"> 65 - <span className="text-gray-300">TANSTACK</span>{' '} 66 - <span className="bg-gradient-to-r from-cyan-400 to-blue-400 bg-clip-text text-transparent"> 67 - START 68 - </span> 69 - </h1> 10 + <div className="min-h-screen bg-gray-950 text-gray-50"> 11 + <div className="container mx-auto px-4 py-16 max-w-4xl"> 12 + <div className="text-center mb-12"> 13 + <div className="flex justify-center mb-6"> 14 + <Film className="w-16 h-16 text-purple-500" /> 70 15 </div> 71 - <p className="text-2xl md:text-3xl text-gray-300 mb-4 font-light"> 72 - The framework for next generation AI applications 16 + <h1 className="text-5xl font-bold mb-4">OpnShelf</h1> 17 + <p className="text-xl text-gray-400 mb-8"> 18 + Your personal media tracker powered by AT Protocol 73 19 </p> 74 - <p className="text-lg text-gray-400 max-w-3xl mx-auto mb-8"> 75 - Full-stack framework powered by TanStack Router for React and Solid. 76 - Build modern applications with server functions, streaming, and type 77 - safety. 78 - </p> 79 - <div className="flex flex-col items-center gap-4"> 80 - <a 81 - href="https://tanstack.com/start" 82 - target="_blank" 83 - rel="noopener noreferrer" 84 - className="px-8 py-3 bg-cyan-500 hover:bg-cyan-600 text-white font-semibold rounded-lg transition-colors shadow-lg shadow-cyan-500/50" 85 - > 86 - Documentation 87 - </a> 88 - <p className="text-gray-400 text-sm mt-2"> 89 - Begin your TanStack Start journey by editing{' '} 90 - <code className="px-2 py-1 bg-slate-700 rounded text-cyan-400"> 91 - /src/routes/index.tsx 92 - </code> 93 - </p> 94 - </div> 20 + <Link 21 + to="/search" 22 + className="inline-flex items-center gap-2 px-6 py-3 bg-purple-600 hover:bg-purple-700 text-white font-semibold rounded-lg transition-colors" 23 + > 24 + <Search className="w-5 h-5" /> 25 + Search Movies 26 + </Link> 95 27 </div> 96 - </section> 97 28 98 - <section className="py-16 px-6 max-w-7xl mx-auto"> 99 - <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> 100 - {features.map((feature, index) => ( 101 - <div 102 - key={index} 103 - className="bg-slate-800/50 backdrop-blur-sm border border-slate-700 rounded-xl p-6 hover:border-cyan-500/50 transition-all duration-300 hover:shadow-lg hover:shadow-cyan-500/10" 104 - > 105 - <div className="mb-4">{feature.icon}</div> 106 - <h3 className="text-xl font-semibold text-white mb-3"> 107 - {feature.title} 108 - </h3> 109 - <p className="text-gray-400 leading-relaxed"> 110 - {feature.description} 111 - </p> 112 - </div> 113 - ))} 29 + <div className="grid md:grid-cols-3 gap-6 mt-16"> 30 + <div className="bg-gray-900 p-6 rounded-lg border border-gray-800"> 31 + <h3 className="text-lg font-semibold mb-2">Track Your Media</h3> 32 + <p className="text-gray-400"> 33 + Keep track of movies, shows, and games you've watched and played 34 + </p> 35 + </div> 36 + <div className="bg-gray-900 p-6 rounded-lg border border-gray-800"> 37 + <h3 className="text-lg font-semibold mb-2">Own Your Data</h3> 38 + <p className="text-gray-400"> 39 + Built on AT Protocol - your data belongs to you 40 + </p> 41 + </div> 42 + <div className="bg-gray-900 p-6 rounded-lg border border-gray-800"> 43 + <h3 className="text-lg font-semibold mb-2">Discover & Share</h3> 44 + <p className="text-gray-400"> 45 + See what others are watching and share your favorites 46 + </p> 47 + </div> 114 48 </div> 115 - </section> 49 + </div> 116 50 </div> 117 - ) 118 - } 51 + ); 52 + }
+129
apps/web/src/routes/search.tsx
··· 1 + import { createFileRoute, useNavigate } from '@tanstack/react-router'; 2 + import { useState, useEffect, useRef } from 'react'; 3 + import { useQuery } from '@tanstack/react-query'; 4 + import { searchMovies } from '@opnshelf/api'; 5 + import { Search } from 'lucide-react'; 6 + 7 + export const Route = createFileRoute('/search')({ 8 + component: SearchPage, 9 + validateSearch: (search: Record<string, unknown>) => ({ 10 + q: (search.q as string) || '', 11 + }), 12 + }); 13 + 14 + const DEBOUNCE_MS = 300; 15 + 16 + function SearchPage() { 17 + const { q: searchQuery } = Route.useSearch(); 18 + const navigate = useNavigate({ from: Route.fullPath }); 19 + const [query, setQuery] = useState(searchQuery); 20 + const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 21 + 22 + // Sync input with URL when navigating back/forward 23 + useEffect(() => { 24 + setQuery(searchQuery); 25 + }, [searchQuery]); 26 + 27 + // Debounced navigation when query changes 28 + useEffect(() => { 29 + if (debounceRef.current) { 30 + clearTimeout(debounceRef.current); 31 + } 32 + 33 + const trimmed = query.trim(); 34 + if (trimmed !== searchQuery) { 35 + debounceRef.current = setTimeout(() => { 36 + navigate({ search: { q: trimmed } }); 37 + }, DEBOUNCE_MS); 38 + } 39 + 40 + return () => { 41 + if (debounceRef.current) { 42 + clearTimeout(debounceRef.current); 43 + } 44 + }; 45 + }, [query, searchQuery, navigate]); 46 + 47 + const { data, isLoading, error } = useQuery({ 48 + queryKey: ['search', searchQuery], 49 + queryFn: () => searchMovies(searchQuery), 50 + enabled: searchQuery.length > 0, 51 + }); 52 + 53 + return ( 54 + <div className="min-h-screen bg-gray-950 text-gray-50"> 55 + <div className="container mx-auto px-4 py-8 max-w-7xl"> 56 + <h1 className="text-4xl font-bold mb-8">Search Movies</h1> 57 + 58 + <div className="mb-8"> 59 + <div className="relative max-w-2xl"> 60 + <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" /> 61 + <input 62 + type="text" 63 + value={query} 64 + onChange={(e) => setQuery(e.target.value)} 65 + placeholder="Search for a movie..." 66 + className="w-full pl-12 pr-4 py-3 bg-gray-900 border border-gray-800 rounded-lg text-gray-50 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent" 67 + /> 68 + </div> 69 + </div> 70 + 71 + {isLoading && ( 72 + <div className="flex justify-center py-12"> 73 + <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-500"></div> 74 + </div> 75 + )} 76 + 77 + {error && ( 78 + <div className="bg-red-900/20 border border-red-900 text-red-400 px-4 py-3 rounded-lg"> 79 + Error: {error.message} 80 + </div> 81 + )} 82 + 83 + {data && data.results.length > 0 && ( 84 + <div> 85 + <p className="text-gray-400 mb-6"> 86 + Found {data.total_results.toLocaleString()} results 87 + </p> 88 + <div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4"> 89 + {data.results.map((movie) => ( 90 + <div 91 + key={movie.id} 92 + className="group cursor-pointer" 93 + > 94 + <div className="relative aspect-2/3 bg-gray-900 rounded-lg overflow-hidden mb-2"> 95 + {movie.poster_path ? ( 96 + <img 97 + src={`https://image.tmdb.org/t/p/w342${movie.poster_path}`} 98 + alt={movie.title} 99 + className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" 100 + /> 101 + ) : ( 102 + <div className="w-full h-full flex items-center justify-center text-gray-600"> 103 + No poster 104 + </div> 105 + )} 106 + </div> 107 + <h3 className="font-semibold text-sm line-clamp-2 mb-1"> 108 + {movie.title} 109 + </h3> 110 + {movie.release_date && ( 111 + <p className="text-gray-500 text-sm"> 112 + {movie.release_date.split('-')[0]} 113 + </p> 114 + )} 115 + </div> 116 + ))} 117 + </div> 118 + </div> 119 + )} 120 + 121 + {data && data.results.length === 0 && searchQuery && ( 122 + <div className="text-center py-12"> 123 + <p className="text-gray-400 text-lg">No results found for "{searchQuery}"</p> 124 + </div> 125 + )} 126 + </div> 127 + </div> 128 + ); 129 + }
+1 -1
package.json
··· 7 7 "dev:mobile": "pnpm --filter mobile start", 8 8 "dev:backend": "pnpm --filter backend start:dev", 9 9 "build": "turbo run build", 10 - "generate:api": "openapi-typescript http://localhost:3001/api-json -o packages/api/src/generated/schema.ts", 10 + "generate:api": "pnpm --filter @opnshelf/api exec openapi-typescript http://localhost:3001/api-json -o src/generated/schema.ts", 11 11 "prisma:generate": "pnpm --filter backend exec prisma generate", 12 12 "prisma:migrate": "pnpm --filter backend exec prisma migrate dev" 13 13 },
+5 -11
packages/api/package.json
··· 1 1 { 2 2 "name": "@opnshelf/api", 3 - "version": "1.0.0", 4 - "description": "", 5 - "main": "index.js", 6 - "scripts": { 7 - "test": "echo \"Error: no test specified\" && exit 1" 8 - }, 9 - "keywords": [], 10 - "author": "", 11 - "license": "ISC", 12 - "packageManager": "pnpm@10.28.2", 3 + "version": "0.0.1", 4 + "main": "src/index.ts", 5 + "types": "src/index.ts", 13 6 "dependencies": { 14 7 "openapi-fetch": "^0.15.0" 15 8 }, 16 9 "devDependencies": { 10 + "@types/node": "^22.10.1", 17 11 "openapi-typescript": "^7.10.1", 18 12 "typescript": "^5.9.3" 19 13 } 20 - } 14 + }
+42
packages/api/src/client.ts
··· 1 + import createClient from 'openapi-fetch'; 2 + import type { paths } from './generated/schema'; 3 + 4 + // Allow configuring base URL 5 + let baseUrl = 'http://localhost:3001'; 6 + 7 + export function configureApiClient(url: string) { 8 + baseUrl = url; 9 + } 10 + 11 + export const apiClient = createClient<paths>({ 12 + get baseUrl() { 13 + return baseUrl; 14 + }, 15 + }); 16 + 17 + export async function searchMovies(query: string) { 18 + const { data, error } = await apiClient.GET('/movies/search', { 19 + params: { query: { query } }, 20 + }); 21 + 22 + if (error) throw new Error('Failed to search movies'); 23 + return data; 24 + } 25 + 26 + export async function getMovieDetails(movieId: string) { 27 + const { data, error } = await apiClient.GET('/movies/tmdb/{movieId}', { 28 + params: { path: { movieId } }, 29 + }); 30 + 31 + if (error) throw new Error('Failed to get movie details'); 32 + return data; 33 + } 34 + 35 + export async function getUserMovies(userDid: string) { 36 + const { data, error } = await apiClient.GET('/movies/user/{userDid}', { 37 + params: { path: { userDid } }, 38 + }); 39 + 40 + if (error) throw new Error('Failed to get user movies'); 41 + return data; 42 + }
+239
packages/api/src/generated/schema.ts
··· 1 + /** 2 + * This file was auto-generated by openapi-typescript. 3 + * Do not make direct changes to the file. 4 + */ 5 + 6 + export interface paths { 7 + "/": { 8 + parameters: { 9 + query?: never; 10 + header?: never; 11 + path?: never; 12 + cookie?: never; 13 + }; 14 + get: operations["AppController_getHello"]; 15 + put?: never; 16 + post?: never; 17 + delete?: never; 18 + options?: never; 19 + head?: never; 20 + patch?: never; 21 + trace?: never; 22 + }; 23 + "/movies/search": { 24 + parameters: { 25 + query?: never; 26 + header?: never; 27 + path?: never; 28 + cookie?: never; 29 + }; 30 + /** Search movies from TMDB */ 31 + get: operations["MoviesController_searchMovies"]; 32 + put?: never; 33 + post?: never; 34 + delete?: never; 35 + options?: never; 36 + head?: never; 37 + patch?: never; 38 + trace?: never; 39 + }; 40 + "/movies/tmdb/{movieId}": { 41 + parameters: { 42 + query?: never; 43 + header?: never; 44 + path?: never; 45 + cookie?: never; 46 + }; 47 + /** Get movie details from TMDB */ 48 + get: operations["MoviesController_getMovieDetails"]; 49 + put?: never; 50 + post?: never; 51 + delete?: never; 52 + options?: never; 53 + head?: never; 54 + patch?: never; 55 + trace?: never; 56 + }; 57 + "/movies/user/{userDid}": { 58 + parameters: { 59 + query?: never; 60 + header?: never; 61 + path?: never; 62 + cookie?: never; 63 + }; 64 + /** Get tracked movies for a user */ 65 + get: operations["MoviesController_getUserMovies"]; 66 + put?: never; 67 + post?: never; 68 + delete?: never; 69 + options?: never; 70 + head?: never; 71 + patch?: never; 72 + trace?: never; 73 + }; 74 + "/movies/{movieId}": { 75 + parameters: { 76 + query?: never; 77 + header?: never; 78 + path?: never; 79 + cookie?: never; 80 + }; 81 + /** Get movie from database */ 82 + get: operations["MoviesController_getMovie"]; 83 + put?: never; 84 + post?: never; 85 + delete?: never; 86 + options?: never; 87 + head?: never; 88 + patch?: never; 89 + trace?: never; 90 + }; 91 + } 92 + export type webhooks = Record<string, never>; 93 + export interface components { 94 + schemas: { 95 + TMDBMovieResultDto: { 96 + id: number; 97 + title: string; 98 + poster_path?: string; 99 + backdrop_path?: string; 100 + release_date?: string; 101 + overview?: string; 102 + }; 103 + SearchResultsDto: { 104 + results: components["schemas"]["TMDBMovieResultDto"][]; 105 + total_results: number; 106 + page: number; 107 + }; 108 + MovieDto: { 109 + movieId: string; 110 + title: string; 111 + posterPath?: string; 112 + backdropPath?: string; 113 + releaseYear?: number; 114 + releaseDate?: string; 115 + overview?: string; 116 + }; 117 + TrackedMovieDto: { 118 + id: string; 119 + rkey: string; 120 + uri: string; 121 + cid: string; 122 + userDid: string; 123 + movieId: string; 124 + status: string; 125 + watchedDate?: string; 126 + createdAt: string; 127 + updatedAt: string; 128 + movie: components["schemas"]["MovieDto"]; 129 + }; 130 + }; 131 + responses: never; 132 + parameters: never; 133 + requestBodies: never; 134 + headers: never; 135 + pathItems: never; 136 + } 137 + export type $defs = Record<string, never>; 138 + export interface operations { 139 + AppController_getHello: { 140 + parameters: { 141 + query?: never; 142 + header?: never; 143 + path?: never; 144 + cookie?: never; 145 + }; 146 + requestBody?: never; 147 + responses: { 148 + 200: { 149 + headers: { 150 + [name: string]: unknown; 151 + }; 152 + content?: never; 153 + }; 154 + }; 155 + }; 156 + MoviesController_searchMovies: { 157 + parameters: { 158 + query: { 159 + /** @description Search term */ 160 + query: string; 161 + }; 162 + header?: never; 163 + path?: never; 164 + cookie?: never; 165 + }; 166 + requestBody?: never; 167 + responses: { 168 + 200: { 169 + headers: { 170 + [name: string]: unknown; 171 + }; 172 + content: { 173 + "application/json": components["schemas"]["SearchResultsDto"]; 174 + }; 175 + }; 176 + }; 177 + }; 178 + MoviesController_getMovieDetails: { 179 + parameters: { 180 + query?: never; 181 + header?: never; 182 + path: { 183 + movieId: string; 184 + }; 185 + cookie?: never; 186 + }; 187 + requestBody?: never; 188 + responses: { 189 + 200: { 190 + headers: { 191 + [name: string]: unknown; 192 + }; 193 + content?: never; 194 + }; 195 + }; 196 + }; 197 + MoviesController_getUserMovies: { 198 + parameters: { 199 + query?: never; 200 + header?: never; 201 + path: { 202 + userDid: string; 203 + }; 204 + cookie?: never; 205 + }; 206 + requestBody?: never; 207 + responses: { 208 + 200: { 209 + headers: { 210 + [name: string]: unknown; 211 + }; 212 + content: { 213 + "application/json": components["schemas"]["TrackedMovieDto"][]; 214 + }; 215 + }; 216 + }; 217 + }; 218 + MoviesController_getMovie: { 219 + parameters: { 220 + query?: never; 221 + header?: never; 222 + path: { 223 + movieId: string; 224 + }; 225 + cookie?: never; 226 + }; 227 + requestBody?: never; 228 + responses: { 229 + 200: { 230 + headers: { 231 + [name: string]: unknown; 232 + }; 233 + content: { 234 + "application/json": components["schemas"]["MovieDto"]; 235 + }; 236 + }; 237 + }; 238 + }; 239 + }
+2
packages/api/src/index.ts
··· 1 + export * from './client'; 2 + export type { paths, components } from './generated/schema';
+11
packages/api/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ES2020", 4 + "module": "ESNext", 5 + "moduleResolution": "bundler", 6 + "strict": true, 7 + "skipLibCheck": true, 8 + "types": ["node"] 9 + }, 10 + "include": ["src/**/*"] 11 + }
+3
pnpm-lock.yaml
··· 275 275 specifier: ^0.15.0 276 276 version: 0.15.0 277 277 devDependencies: 278 + '@types/node': 279 + specifier: ^22.10.1 280 + version: 22.19.7 278 281 openapi-typescript: 279 282 specifier: ^7.10.1 280 283 version: 7.10.1(typescript@5.9.3)