feat: bootstrap temporserv project scaffold

Add the initial project blueprint, Go backend skeleton, frontend app shell, database schema draft, and local development/deployment files.
This commit is contained in:
2026-04-02 22:17:48 +03:00
commit 2b3123a9a7
37 changed files with 4863 additions and 0 deletions

25
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,25 @@
import { Navigate, Route, Routes } from 'react-router-dom'
import { AppShell } from '@/components/app-shell'
import { HomePage } from '@/pages/home-page'
import { LibraryPage } from '@/pages/library-page'
import { LoginPage } from '@/pages/login-page'
import { useSessionStore } from '@/stores/session-store'
export default function App() {
const token = useSessionStore((state) => state.token)
if (!token) {
return <LoginPage />
}
return (
<AppShell>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/library" element={<LibraryPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</AppShell>
)
}

View File

@@ -0,0 +1,85 @@
import { Disc3, Home, Library, LogOut, Search } from 'lucide-react'
import { NavLink } from 'react-router-dom'
import { PlayerBar } from '@/components/player-bar'
import { useSessionStore } from '@/stores/session-store'
export function AppShell({ children }: { children: React.ReactNode }) {
const username = useSessionStore((state) => state.username)
const clearSession = useSessionStore((state) => state.clearSession)
return (
<div className="min-h-screen bg-transparent px-4 py-4 text-ink md:px-6">
<div className="mx-auto grid min-h-[calc(100vh-2rem)] max-w-7xl grid-cols-1 gap-4 lg:grid-cols-[240px_minmax(0,1fr)]">
<aside className="rounded-[28px] border border-line bg-panel/80 p-5 shadow-glow backdrop-blur">
<div className="mb-8 flex items-center gap-3">
<div className="rounded-2xl bg-accent p-3 text-slate-900">
<Disc3 size={22} />
</div>
<div>
<div className="font-display text-lg font-semibold">TemporServ</div>
<div className="text-sm text-slate-400">Subsonic-ready music server</div>
</div>
</div>
<nav className="space-y-2">
<SidebarLink to="/" icon={<Home size={18} />} label="Home" />
<SidebarLink to="/library" icon={<Library size={18} />} label="Library" />
<button
className="flex w-full items-center gap-3 rounded-2xl border border-transparent px-4 py-3 text-left text-slate-300 transition hover:border-line hover:bg-slate-800/40"
type="button"
>
<Search size={18} />
Search
</button>
</nav>
<div className="mt-10 rounded-3xl border border-line bg-slate-900/50 p-4">
<div className="text-sm text-slate-400">Signed in as</div>
<div className="mt-1 font-medium">{username ?? 'demo'}</div>
<button
className="mt-4 flex w-full items-center justify-center gap-2 rounded-2xl bg-slate-100 px-4 py-3 text-sm font-semibold text-slate-900 transition hover:bg-white"
onClick={clearSession}
type="button"
>
<LogOut size={16} />
Sign out
</button>
</div>
</aside>
<main className="flex min-h-0 flex-col gap-4">
<section className="min-h-0 flex-1 rounded-[28px] border border-line bg-panel/60 p-5 backdrop-blur md:p-6">
{children}
</section>
<PlayerBar />
</main>
</div>
</div>
)
}
function SidebarLink({
to,
icon,
label,
}: {
to: string
icon: React.ReactNode
label: string
}) {
return (
<NavLink
to={to}
className={({ isActive }) =>
[
'flex items-center gap-3 rounded-2xl px-4 py-3 transition',
isActive ? 'bg-accent text-slate-900' : 'text-slate-300 hover:bg-slate-800/40',
].join(' ')
}
>
{icon}
{label}
</NavLink>
)
}

View File

@@ -0,0 +1,53 @@
import { Pause, Play, SkipBack, SkipForward, Volume2 } from 'lucide-react'
import { usePlayerStore } from '@/stores/player-store'
export function PlayerBar() {
const currentTrack = usePlayerStore((state) => state.currentTrack)
return (
<section className="grid gap-4 rounded-[28px] border border-line bg-slate-950/70 p-4 backdrop-blur md:grid-cols-[1.3fr_auto_1fr] md:items-center">
<div>
<div className="text-xs uppercase tracking-[0.24em] text-slate-500">Now playing</div>
<div className="mt-1 text-lg font-semibold">{currentTrack?.title ?? 'Nothing queued yet'}</div>
<div className="text-sm text-slate-400">
{currentTrack ? `${currentTrack.artistName}${currentTrack.albumTitle}` : 'Wire this to /api/stream next'}
</div>
</div>
<div className="flex items-center justify-center gap-3">
<ControlButton icon={<SkipBack size={16} />} />
<ControlButton icon={<Play size={16} />} active />
<ControlButton icon={<Pause size={16} />} />
<ControlButton icon={<SkipForward size={16} />} />
</div>
<div className="flex items-center gap-3 md:justify-end">
<Volume2 size={16} className="text-slate-400" />
<div className="h-2 w-full max-w-40 rounded-full bg-slate-800">
<div className="h-2 w-2/3 rounded-full bg-accent" />
</div>
</div>
</section>
)
}
function ControlButton({
icon,
active = false,
}: {
icon: React.ReactNode
active?: boolean
}) {
return (
<button
className={[
'flex h-11 w-11 items-center justify-center rounded-full border transition',
active ? 'border-accent bg-accent text-slate-900' : 'border-line bg-slate-900/70 text-ink hover:bg-slate-800',
].join(' ')}
type="button"
>
{icon}
</button>
)
}

View File

@@ -0,0 +1,18 @@
export function SectionTitle({
eyebrow,
title,
copy,
}: {
eyebrow: string
title: string
copy: string
}) {
return (
<header className="mb-6">
<div className="text-xs uppercase tracking-[0.26em] text-accentSoft">{eyebrow}</div>
<h1 className="mt-2 text-3xl font-semibold md:text-4xl">{title}</h1>
<p className="mt-2 max-w-2xl text-sm text-slate-400 md:text-base">{copy}</p>
</header>
)
}

66
apps/web/src/lib/api.ts Normal file
View File

@@ -0,0 +1,66 @@
export type User = {
id: string
username: string
isAdmin: boolean
}
export type HomePayload = {
recentAlbums: Array<{
id: string
artistId: string
artistName: string
title: string
year: number
trackCount: number
}>
artists: Array<{
id: string
name: string
albumCount: number
}>
}
export type Track = {
id: string
albumId: string
artistId: string
title: string
artistName: string
albumTitle: string
trackNumber: number
durationSeconds: number
}
const API_BASE = import.meta.env.VITE_API_BASE ?? 'http://localhost:4040'
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
headers: {
'Content-Type': 'application/json',
...(init?.headers ?? {}),
},
...init,
})
if (!response.ok) {
throw new Error(`Request failed: ${response.status}`)
}
return response.json() as Promise<T>
}
export async function login(username: string, password: string) {
return request<{ token: string; user: User }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
})
}
export async function fetchHome() {
return request<HomePayload>('/api/home')
}
export async function fetchTracks() {
return request<{ items: Track[] }>('/api/tracks')
}

19
apps/web/src/main.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom'
import App from '@/App'
import '@/styles.css'
const queryClient = new QueryClient()
createRoot(document.getElementById('root') as HTMLElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
)

View File

@@ -0,0 +1,60 @@
import { useQuery } from '@tanstack/react-query'
import { fetchHome } from '@/lib/api'
import { SectionTitle } from '@/components/section-title'
export function HomePage() {
const homeQuery = useQuery({
queryKey: ['home'],
queryFn: fetchHome,
})
const home = homeQuery.data
return (
<div>
<SectionTitle
eyebrow="Overview"
title="Aonsoku-like web UI on top of your own server"
copy="This first scaffold gives us a styled shell, data fetching boundaries, and a clean place to add real library, scan, and playback flows."
/>
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
<section className="rounded-[28px] border border-line bg-slate-950/35 p-5">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold">Recent albums</h2>
<span className="text-sm text-slate-500">{homeQuery.isLoading ? 'Loading...' : 'Demo payload'}</span>
</div>
<div className="grid gap-4 md:grid-cols-3">
{home?.recentAlbums.map((album) => (
<article key={album.id} className="rounded-[24px] border border-line bg-panel p-4">
<div className="aspect-square rounded-[20px] bg-[linear-gradient(145deg,#1f2f45,#152236)]" />
<div className="mt-4 text-lg font-semibold">{album.title}</div>
<div className="text-sm text-slate-400">{album.artistName}</div>
<div className="mt-2 text-xs uppercase tracking-[0.22em] text-slate-500">
{album.year} {album.trackCount} tracks
</div>
</article>
))}
</div>
</section>
<section className="rounded-[28px] border border-line bg-slate-950/35 p-5">
<h2 className="text-xl font-semibold">Artists</h2>
<div className="mt-4 space-y-3">
{home?.artists.map((artist) => (
<div key={artist.id} className="flex items-center justify-between rounded-2xl border border-line bg-panel px-4 py-3">
<div>
<div className="font-medium">{artist.name}</div>
<div className="text-sm text-slate-400">{artist.albumCount} albums</div>
</div>
<div className="text-xs uppercase tracking-[0.22em] text-accentSoft">Artist</div>
</div>
))}
</div>
</section>
</div>
</div>
)
}

View File

@@ -0,0 +1,67 @@
import { useQuery } from '@tanstack/react-query'
import { SectionTitle } from '@/components/section-title'
import { fetchTracks } from '@/lib/api'
import { usePlayerStore } from '@/stores/player-store'
export function LibraryPage() {
const setQueue = usePlayerStore((state) => state.setQueue)
const playTrack = usePlayerStore((state) => state.playTrack)
const tracksQuery = useQuery({
queryKey: ['tracks'],
queryFn: fetchTracks,
})
const tracks = tracksQuery.data?.items ?? []
return (
<div>
<SectionTitle
eyebrow="Library"
title="Tracks, queue, and playback boundaries"
copy="This is the first useful slice for the app: list tracks from the backend, seed the queue, and hand off current track state to the global player."
/>
<div className="mb-4 flex gap-3">
<button
className="rounded-2xl bg-accent px-4 py-3 font-semibold text-slate-900"
onClick={() => setQueue(tracks)}
type="button"
>
Queue all
</button>
</div>
<div className="overflow-hidden rounded-[28px] border border-line bg-slate-950/35">
<div className="grid grid-cols-[72px_minmax(0,1.4fr)_minmax(0,1fr)_100px] gap-3 border-b border-line px-4 py-3 text-xs uppercase tracking-[0.24em] text-slate-500">
<div>#</div>
<div>Track</div>
<div>Album</div>
<div>Length</div>
</div>
{tracks.map((track) => (
<button
key={track.id}
className="grid w-full grid-cols-[72px_minmax(0,1.4fr)_minmax(0,1fr)_100px] gap-3 border-b border-line/60 px-4 py-4 text-left transition hover:bg-slate-900/60"
onClick={() => playTrack(track)}
type="button"
>
<div className="text-slate-500">{track.trackNumber}</div>
<div>
<div className="font-medium">{track.title}</div>
<div className="text-sm text-slate-400">{track.artistName}</div>
</div>
<div className="text-slate-300">{track.albumTitle}</div>
<div className="text-slate-400">{formatDuration(track.durationSeconds)}</div>
</button>
))}
</div>
</div>
)
}
function formatDuration(durationSeconds: number) {
const minutes = Math.floor(durationSeconds / 60)
const seconds = durationSeconds % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}

View File

@@ -0,0 +1,114 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { login } from '@/lib/api'
import { useSessionStore } from '@/stores/session-store'
const schema = z.object({
username: z.string().min(1, 'Username is required'),
password: z.string().min(1, 'Password is required'),
})
type LoginForm = z.infer<typeof schema>
export function LoginPage() {
const setSession = useSessionStore((state) => state.setSession)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginForm>({
resolver: zodResolver(schema),
defaultValues: {
username: 'demo',
password: 'demo',
},
})
const mutation = useMutation({
mutationFn: (values: LoginForm) => login(values.username, values.password),
onSuccess: (result) => {
setSession(result.token, result.user.username)
},
})
return (
<div className="flex min-h-screen items-center justify-center px-4 py-8">
<div className="grid w-full max-w-6xl overflow-hidden rounded-[36px] border border-line bg-panel/70 shadow-glow backdrop-blur lg:grid-cols-[1.1fr_0.9fr]">
<section className="hidden min-h-[640px] flex-col justify-between bg-[linear-gradient(180deg,rgba(242,159,103,0.14),rgba(17,28,44,0.02))] p-10 lg:flex">
<div className="max-w-md">
<div className="text-xs uppercase tracking-[0.3em] text-accentSoft">TemporServ</div>
<h1 className="mt-4 text-5xl font-semibold leading-tight">
Self-hosted streaming with a modern listening UI.
</h1>
<p className="mt-4 text-base text-slate-300">
This scaffold already separates the Subsonic-friendly backend from a dedicated web client.
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<InfoCard label="Backend" value="Go + chi" />
<InfoCard label="Web" value="React + Vite" />
<InfoCard label="Direction" value="Navidrome + Aonsoku" />
</div>
</section>
<section className="p-6 md:p-10">
<div className="mx-auto max-w-md">
<div className="text-sm uppercase tracking-[0.26em] text-accentSoft">Sign in</div>
<h2 className="mt-3 text-3xl font-semibold">Open your library</h2>
<p className="mt-2 text-sm text-slate-400">
Demo login is wired to the placeholder backend endpoint.
</p>
<form className="mt-8 space-y-5" onSubmit={handleSubmit((values) => mutation.mutate(values))}>
<label className="block">
<span className="mb-2 block text-sm text-slate-300">Username</span>
<input
className="w-full rounded-2xl border border-line bg-slate-950/60 px-4 py-3 text-ink outline-none transition focus:border-accent"
{...register('username')}
/>
{errors.username ? <span className="mt-2 block text-sm text-red-300">{errors.username.message}</span> : null}
</label>
<label className="block">
<span className="mb-2 block text-sm text-slate-300">Password</span>
<input
className="w-full rounded-2xl border border-line bg-slate-950/60 px-4 py-3 text-ink outline-none transition focus:border-accent"
type="password"
{...register('password')}
/>
{errors.password ? <span className="mt-2 block text-sm text-red-300">{errors.password.message}</span> : null}
</label>
<button
className="w-full rounded-2xl bg-accent px-4 py-3 font-semibold text-slate-900 transition hover:brightness-105 disabled:opacity-60"
disabled={mutation.isPending}
type="submit"
>
{mutation.isPending ? 'Signing in...' : 'Sign in'}
</button>
{mutation.isError ? (
<div className="rounded-2xl border border-red-400/30 bg-red-500/10 px-4 py-3 text-sm text-red-200">
Could not reach the backend. Make sure the API is running on `http://localhost:4040`.
</div>
) : null}
</form>
</div>
</section>
</div>
</div>
)
}
function InfoCard({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-3xl border border-line bg-slate-950/35 p-4">
<div className="text-xs uppercase tracking-[0.24em] text-slate-500">{label}</div>
<div className="mt-2 text-lg font-medium">{value}</div>
</div>
)
}

View File

@@ -0,0 +1,16 @@
import { create } from 'zustand'
import type { Track } from '@/lib/api'
type PlayerState = {
currentTrack: Track | null
queue: Track[]
setQueue: (tracks: Track[]) => void
playTrack: (track: Track) => void
}
export const usePlayerStore = create<PlayerState>((set) => ({
currentTrack: null,
queue: [],
setQueue: (queue) => set({ queue, currentTrack: queue[0] ?? null }),
playTrack: (currentTrack) => set({ currentTrack }),
}))

View File

@@ -0,0 +1,16 @@
import { create } from 'zustand'
type SessionState = {
token: string | null
username: string | null
setSession: (token: string, username: string) => void
clearSession: () => void
}
export const useSessionStore = create<SessionState>((set) => ({
token: null,
username: null,
setSession: (token, username) => set({ token, username }),
clearSession: () => set({ token: null, username: null }),
}))

40
apps/web/src/styles.css Normal file
View File

@@ -0,0 +1,40 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
color: #dce6f2;
background:
radial-gradient(circle at top, rgba(242, 159, 103, 0.16), transparent 24%),
linear-gradient(180deg, #0c1624 0%, #09111c 100%);
font-family: "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body,
#root {
min-height: 100%;
margin: 0;
}
body {
color: #dce6f2;
background:
radial-gradient(circle at top, rgba(242, 159, 103, 0.16), transparent 24%),
linear-gradient(180deg, #0c1624 0%, #09111c 100%);
}
a {
color: inherit;
text-decoration: none;
}
button,
input {
font: inherit;
}

1
apps/web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />