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:
25
apps/web/src/App.tsx
Normal file
25
apps/web/src/App.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
85
apps/web/src/components/app-shell.tsx
Normal file
85
apps/web/src/components/app-shell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
53
apps/web/src/components/player-bar.tsx
Normal file
53
apps/web/src/components/player-bar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
18
apps/web/src/components/section-title.tsx
Normal file
18
apps/web/src/components/section-title.tsx
Normal 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
66
apps/web/src/lib/api.ts
Normal 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
19
apps/web/src/main.tsx
Normal 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>,
|
||||
)
|
||||
|
||||
60
apps/web/src/pages/home-page.tsx
Normal file
60
apps/web/src/pages/home-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
67
apps/web/src/pages/library-page.tsx
Normal file
67
apps/web/src/pages/library-page.tsx
Normal 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')}`
|
||||
}
|
||||
114
apps/web/src/pages/login-page.tsx
Normal file
114
apps/web/src/pages/login-page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
16
apps/web/src/stores/player-store.ts
Normal file
16
apps/web/src/stores/player-store.ts
Normal 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 }),
|
||||
}))
|
||||
16
apps/web/src/stores/session-store.ts
Normal file
16
apps/web/src/stores/session-store.ts
Normal 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
40
apps/web/src/styles.css
Normal 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
1
apps/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user