add shared frontend: router, i18n, ui store, api client

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 13:52:49 +01:00
parent 2fdaf870b6
commit 1d444e6e4e
18 changed files with 497 additions and 0 deletions

View File

@@ -1,5 +1,8 @@
{
"$schema": "https://biomejs.dev/schemas/1.9.0/schema.json",
"files": {
"ignore": ["src/routeTree.gen.ts"]
},
"organizeImports": {
"enabled": true
},

22
src/main.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { RouterProvider, createRouter } from "@tanstack/react-router"
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { routeTree } from "./routeTree.gen"
import "./app.css"
const router = createRouter({ routeTree })
declare module "@tanstack/react-router" {
interface Register {
router: typeof router
}
}
const root = document.getElementById("root")
if (!root) throw new Error("Root element not found")
createRoot(root).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
)

189
src/routeTree.gen.ts Normal file
View File

@@ -0,0 +1,189 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as SettingsIndexRouteImport } from './routes/settings/index'
import { Route as PlaylistsIndexRouteImport } from './routes/playlists/index'
import { Route as LibraryIndexRouteImport } from './routes/library/index'
import { Route as DiscoverIndexRouteImport } from './routes/discover/index'
import { Route as SettingsProviderRouteImport } from './routes/settings/$provider'
import { Route as PlaylistsPlaylistIdRouteImport } from './routes/playlists/$playlistId'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsIndexRoute = SettingsIndexRouteImport.update({
id: '/settings/',
path: '/settings/',
getParentRoute: () => rootRouteImport,
} as any)
const PlaylistsIndexRoute = PlaylistsIndexRouteImport.update({
id: '/playlists/',
path: '/playlists/',
getParentRoute: () => rootRouteImport,
} as any)
const LibraryIndexRoute = LibraryIndexRouteImport.update({
id: '/library/',
path: '/library/',
getParentRoute: () => rootRouteImport,
} as any)
const DiscoverIndexRoute = DiscoverIndexRouteImport.update({
id: '/discover/',
path: '/discover/',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsProviderRoute = SettingsProviderRouteImport.update({
id: '/settings/$provider',
path: '/settings/$provider',
getParentRoute: () => rootRouteImport,
} as any)
const PlaylistsPlaylistIdRoute = PlaylistsPlaylistIdRouteImport.update({
id: '/playlists/$playlistId',
path: '/playlists/$playlistId',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/playlists/$playlistId': typeof PlaylistsPlaylistIdRoute
'/settings/$provider': typeof SettingsProviderRoute
'/discover/': typeof DiscoverIndexRoute
'/library/': typeof LibraryIndexRoute
'/playlists/': typeof PlaylistsIndexRoute
'/settings/': typeof SettingsIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/playlists/$playlistId': typeof PlaylistsPlaylistIdRoute
'/settings/$provider': typeof SettingsProviderRoute
'/discover': typeof DiscoverIndexRoute
'/library': typeof LibraryIndexRoute
'/playlists': typeof PlaylistsIndexRoute
'/settings': typeof SettingsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/playlists/$playlistId': typeof PlaylistsPlaylistIdRoute
'/settings/$provider': typeof SettingsProviderRoute
'/discover/': typeof DiscoverIndexRoute
'/library/': typeof LibraryIndexRoute
'/playlists/': typeof PlaylistsIndexRoute
'/settings/': typeof SettingsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/playlists/$playlistId'
| '/settings/$provider'
| '/discover/'
| '/library/'
| '/playlists/'
| '/settings/'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/playlists/$playlistId'
| '/settings/$provider'
| '/discover'
| '/library'
| '/playlists'
| '/settings'
id:
| '__root__'
| '/'
| '/playlists/$playlistId'
| '/settings/$provider'
| '/discover/'
| '/library/'
| '/playlists/'
| '/settings/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
PlaylistsPlaylistIdRoute: typeof PlaylistsPlaylistIdRoute
SettingsProviderRoute: typeof SettingsProviderRoute
DiscoverIndexRoute: typeof DiscoverIndexRoute
LibraryIndexRoute: typeof LibraryIndexRoute
PlaylistsIndexRoute: typeof PlaylistsIndexRoute
SettingsIndexRoute: typeof SettingsIndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/settings/': {
id: '/settings/'
path: '/settings'
fullPath: '/settings/'
preLoaderRoute: typeof SettingsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/playlists/': {
id: '/playlists/'
path: '/playlists'
fullPath: '/playlists/'
preLoaderRoute: typeof PlaylistsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/library/': {
id: '/library/'
path: '/library'
fullPath: '/library/'
preLoaderRoute: typeof LibraryIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/discover/': {
id: '/discover/'
path: '/discover'
fullPath: '/discover/'
preLoaderRoute: typeof DiscoverIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/settings/$provider': {
id: '/settings/$provider'
path: '/settings/$provider'
fullPath: '/settings/$provider'
preLoaderRoute: typeof SettingsProviderRouteImport
parentRoute: typeof rootRouteImport
}
'/playlists/$playlistId': {
id: '/playlists/$playlistId'
path: '/playlists/$playlistId'
fullPath: '/playlists/$playlistId'
preLoaderRoute: typeof PlaylistsPlaylistIdRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
PlaylistsPlaylistIdRoute: PlaylistsPlaylistIdRoute,
SettingsProviderRoute: SettingsProviderRoute,
DiscoverIndexRoute: DiscoverIndexRoute,
LibraryIndexRoute: LibraryIndexRoute,
PlaylistsIndexRoute: PlaylistsIndexRoute,
SettingsIndexRoute: SettingsIndexRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()

42
src/routes/__root.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { t } from "@/shared/i18n"
import { Link, Outlet, createRootRoute } from "@tanstack/react-router"
import { Gamepad2, Library, ListMusic, Settings } from "lucide-react"
export const Route = createRootRoute({
component: RootLayout,
})
function RootLayout() {
return (
<div className="flex min-h-dvh flex-col bg-background">
<main className="flex-1 overflow-y-auto pb-20">
<Outlet />
</main>
<nav className="fixed inset-x-0 bottom-0 border-t bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex max-w-lg justify-around py-2">
<NavLink to="/library" icon={Library} label={t("nav.library")} />
<NavLink to="/discover" icon={Gamepad2} label={t("nav.discover")} />
<NavLink to="/playlists" icon={ListMusic} label={t("nav.playlists")} />
<NavLink to="/settings" icon={Settings} label={t("nav.settings")} />
</div>
</nav>
</div>
)
}
function NavLink({
to,
icon: Icon,
label,
}: { to: string; icon: React.ComponentType<{ className?: string }>; label: string }) {
return (
<Link
to={to}
className="flex flex-col items-center gap-1 px-3 py-1 text-muted-foreground transition-colors [&.active]:text-foreground"
activeProps={{ className: "active text-foreground" }}
>
<Icon className="h-5 w-5" />
<span className="text-xs">{label}</span>
</Link>
)
}

View File

@@ -0,0 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/discover/")({
component: () => <div>Discover</div>,
})

5
src/routes/index.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { Navigate, createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/")({
component: () => <Navigate to="/library" />,
})

View File

@@ -0,0 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/library/")({
component: () => <div>Library</div>,
})

View File

@@ -0,0 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/playlists/$playlistId")({
component: () => <div>Playlist Detail</div>,
})

View File

@@ -0,0 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/playlists/")({
component: () => <div>Playlists</div>,
})

View File

@@ -0,0 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/settings/$provider")({
component: () => <div>Provider Settings</div>,
})

View File

@@ -0,0 +1,5 @@
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/settings/")({
component: () => <div>Settings</div>,
})

28
src/shared/i18n/index.ts Normal file
View File

@@ -0,0 +1,28 @@
import { de } from "./locales/de"
import { type TranslationKey, en } from "./locales/en"
import { es } from "./locales/es"
import { fr } from "./locales/fr"
type Locale = "en" | "de" | "es" | "fr"
const translations: Record<Locale, Partial<Record<TranslationKey, string>>> = {
en,
de,
es,
fr,
}
let currentLocale: Locale = "en"
export function setLocale(locale: Locale) {
currentLocale = locale
}
export function t(key: TranslationKey, params?: Record<string, string | number>): string {
const value = translations[currentLocale][key] ?? translations.en[key] ?? key
if (!params) return value
return Object.entries(params).reduce<string>(
(acc, [k, v]) => acc.replace(`{${k}}`, String(v)),
value,
)
}

View File

@@ -0,0 +1,64 @@
import type { TranslationKey } from "./en"
export const de: Record<TranslationKey, string> = {
"nav.library": "Bibliothek",
"nav.discover": "Entdecken",
"nav.playlists": "Playlisten",
"nav.settings": "Einstellungen",
"library.title": "Bibliothek",
"library.search": "Spiele suchen...",
"library.empty": "Noch keine Spiele. Füge Anbieter in den Einstellungen hinzu.",
"library.games": "Spiele",
"library.hours": "Stunden gespielt",
"library.sort.title": "Titel",
"library.sort.playtime": "Spielzeit",
"library.sort.lastPlayed": "Zuletzt gespielt",
"discover.title": "Entdecken",
"discover.empty": "Keine weiteren Spiele zum Entdecken!",
"discover.progress": "Fortschritt",
"discover.reset": "Zurücksetzen",
"discover.done.title": "Alles erledigt!",
"discover.done.message": "Du hast alle Spiele durchgesehen.",
"playlists.title": "Playlisten",
"playlists.create": "Playlist erstellen",
"playlists.empty": "Noch keine Playlisten.",
"playlists.addGames": "Spiele hinzufügen...",
"playlists.noGames": "Keine Spiele in dieser Playlist.",
"settings.title": "Einstellungen",
"settings.providers": "Anbieter",
"settings.data": "Daten",
"settings.steam": "Steam",
"settings.gog": "GOG",
"settings.steam.apiKey": "API-Schlüssel",
"settings.steam.steamId": "Steam-ID",
"settings.steam.sync": "Spiele synchronisieren",
"settings.gog.code": "Autorisierungscode",
"settings.gog.connect": "Verbinden",
"settings.gog.disconnect": "Trennen",
"settings.data.export": "Daten exportieren",
"settings.data.import": "Daten importieren",
"settings.data.clear": "Alle Daten löschen",
"settings.data.clearConfirm": "Bist du sicher? Alle Spiele und Playlisten werden gelöscht.",
"settings.lastSync": "Letzte Synchronisierung",
"settings.syncing": "Synchronisiere...",
"settings.syncSuccess": "{count} Spiele synchronisiert",
"state.not_set": "Nicht gesetzt",
"state.wishlisted": "Gewünscht",
"state.playlisted": "Eingeplant",
"state.playing": "Spielt gerade",
"state.finished": "Abgeschlossen",
"state.perfected": "Perfektioniert",
"state.abandoned": "Aufgegeben",
"state.bad_game": "Schlechtes Spiel",
"general.save": "Speichern",
"general.cancel": "Abbrechen",
"general.delete": "Löschen",
"general.confirm": "Bestätigen",
"general.loading": "Laden...",
}

View File

@@ -0,0 +1,71 @@
export const en = {
// Navigation
"nav.library": "Library",
"nav.discover": "Discover",
"nav.playlists": "Playlists",
"nav.settings": "Settings",
// Library
"library.title": "Library",
"library.search": "Search games...",
"library.empty": "No games yet. Add providers in Settings.",
"library.games": "games",
"library.hours": "hours played",
"library.sort.title": "Title",
"library.sort.playtime": "Playtime",
"library.sort.lastPlayed": "Last Played",
// Discover
"discover.title": "Discover",
"discover.empty": "No more games to discover!",
"discover.progress": "Progress",
"discover.reset": "Reset",
"discover.done.title": "All caught up!",
"discover.done.message": "You've gone through all your games.",
// Playlists
"playlists.title": "Playlists",
"playlists.create": "Create Playlist",
"playlists.empty": "No playlists yet.",
"playlists.addGames": "Add games...",
"playlists.noGames": "No games in this playlist.",
// Settings
"settings.title": "Settings",
"settings.providers": "Providers",
"settings.data": "Data",
"settings.steam": "Steam",
"settings.gog": "GOG",
"settings.steam.apiKey": "API Key",
"settings.steam.steamId": "Steam ID",
"settings.steam.sync": "Sync Games",
"settings.gog.code": "Authorization Code",
"settings.gog.connect": "Connect",
"settings.gog.disconnect": "Disconnect",
"settings.data.export": "Export Data",
"settings.data.import": "Import Data",
"settings.data.clear": "Clear All Data",
"settings.data.clearConfirm": "Are you sure? This will delete all games and playlists.",
"settings.lastSync": "Last sync",
"settings.syncing": "Syncing...",
"settings.syncSuccess": "Synced {count} games",
// Game states
"state.not_set": "Not Set",
"state.wishlisted": "Wishlisted",
"state.playlisted": "Playlisted",
"state.playing": "Playing",
"state.finished": "Finished",
"state.perfected": "Perfected",
"state.abandoned": "Abandoned",
"state.bad_game": "Bad Game",
// General
"general.save": "Save",
"general.cancel": "Cancel",
"general.delete": "Delete",
"general.confirm": "Confirm",
"general.loading": "Loading...",
} as const
export type TranslationKey = keyof typeof en

View File

@@ -0,0 +1,5 @@
import type { TranslationKey } from "./en"
export const es: Partial<Record<TranslationKey, string>> = {
// TODO: complete Spanish translations
}

View File

@@ -0,0 +1,5 @@
import type { TranslationKey } from "./en"
export const fr: Partial<Record<TranslationKey, string>> = {
// TODO: complete French translations
}

6
src/shared/lib/api.ts Normal file
View File

@@ -0,0 +1,6 @@
import { hc } from "hono/client"
import type { AppType } from "../../../server/src/app"
const baseUrl = import.meta.env.VITE_API_URL || ""
export const api = hc<AppType>(baseUrl)

View File

@@ -0,0 +1,27 @@
import { create } from "zustand"
type SortBy = "title" | "playtime" | "lastPlayed"
type SortDirection = "asc" | "desc"
interface UiState {
searchText: string
sortBy: SortBy
sortDirection: SortDirection
activeTab: string
setSearchText: (text: string) => void
setSortBy: (sort: SortBy) => void
toggleSortDirection: () => void
setActiveTab: (tab: string) => void
}
export const useUiStore = create<UiState>((set) => ({
searchText: "",
sortBy: "title",
sortDirection: "asc",
activeTab: "library",
setSearchText: (searchText) => set({ searchText }),
setSortBy: (sortBy) => set({ sortBy }),
toggleSortDirection: () =>
set((s) => ({ sortDirection: s.sortDirection === "asc" ? "desc" : "asc" })),
setActiveTab: (activeTab) => set({ activeTab }),
}))