Docker build

This commit is contained in:
2026-05-05 20:56:06 +03:00
parent 4d03cb94ae
commit 7b7254c0b0
39 changed files with 3778 additions and 38 deletions
+9
View File
@@ -0,0 +1,9 @@
.git
**/.DS_Store
**/node_modules
admin-web/dist
backend/.build
**/DerivedData
**/*.xcuserstate
Packages/**/.build
Apps/**/build
+10
View File
@@ -0,0 +1,10 @@
# Root env template (optional). Docker Compose loads `.env` from this directory when you run `docker compose`.
#
# `VITE_*` variables affect `docker compose --profile compile run --rm admin-compile` because Vite inlines them at build time.
VITE_ADMIN_API_BASE_URL=http://localhost:8080
# --- Future (docs/plan.md Phase 0) ---
# POSTGRES_USER=radiostore
# POSTGRES_PASSWORD=
# POSTGRES_DB=radiostore
+1
View File
@@ -15,6 +15,7 @@ DerivedData/
.build/
*.ipa
*.dSYM.zip
**/xcschememanagement.plist
# Vapor / Swift backend
backend/.build/
@@ -25,7 +25,7 @@
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.GDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
@@ -5,46 +5,59 @@ private enum VolumeStep {
static let delta: Float = 0.05
}
/// Playback chrome: play / pause and in-player volume (mix) control tuned for tvOS focus navigation.
/// Playback driven by the **Siri Remote**: **Play/Pause** toggles transport; **touch surface up / down**
/// adjusts ``RadioPlayerController/volume`` (player mix gain, not HDMI TV volume).
public struct RadioPlayerView: View {
private let station: RadioStation
@State private var controller = RadioPlayerController()
@Namespace private var playbackFocus
public init(station: RadioStation) {
self.station = station
}
public var body: some View {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 20) {
Text(station.title)
.font(.title2.weight(.semibold))
HStack(spacing: 32) {
Button(controller.isPlaying ? "Pause" : "Play") {
controller.togglePlayback()
}
.buttonStyle(.borderedProminent)
Text(controller.isPlaying ? "Playing" : "Paused")
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
VStack(alignment: .leading, spacing: 8) {
Text("Player volume")
.font(.caption)
.foregroundStyle(.secondary)
Text(volumePercentLabel)
.font(.body.monospacedDigit())
.foregroundStyle(.secondary)
HStack(spacing: 16) {
Button("Quieter") {
controller.volume -= VolumeStep.delta
}
Button("Louder") {
controller.volume += VolumeStep.delta
}
}
}
HStack(alignment: .firstTextBaseline) {
Text("Mix volume")
.foregroundStyle(.secondary)
Spacer(minLength: 24)
Text(volumePercentLabel)
.font(.body.monospacedDigit())
.foregroundStyle(.secondary)
}
Text("Press Play/Pause on the remote. Swipe up or down on the touch surface to change mix volume.")
.font(.caption)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
.padding(.top, 8)
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.focusScope(playbackFocus)
.focusable()
.prefersDefaultFocus(true, in: playbackFocus)
.onPlayPauseCommand {
controller.togglePlayback()
}
.onMoveCommand { direction in
switch direction {
case .up:
controller.volume += VolumeStep.delta
case .down:
controller.volume -= VolumeStep.delta
default:
break
}
}
.onAppear {
controller.load(station: station)
}
+2
View File
@@ -0,0 +1,2 @@
# Origin of the Vapor admin API (no trailing slash). CORS must allow http://localhost:5173 in dev.
VITE_ADMIN_API_BASE_URL=http://localhost:8080
+46
View File
@@ -0,0 +1,46 @@
# RadioStore Admin (React)
Vite + React + TypeScript + React Router + [React Bootstrap](https://react-bootstrap.netlify.app/).
## Setup
```bash
cd admin-web
cp .env.example .env
npm install
npm run dev
```
Dev server defaults to **http://localhost:5173**. Set **`VITE_ADMIN_API_BASE_URL`** in `.env` to your Vapor origin (no trailing slash). The backend must allow **CORS** for this origin.
## Backend routes (expected)
The UI calls **`/admin/v1/...`** relative to that base URL:
| Method | Path | Notes |
|--------|------|--------|
| POST | `/admin/v1/auth/login` | Body `{ email, password }`; JSON `{ access_token }` or `{ token }` |
| GET | `/admin/v1/taxonomy` | `{ geos: [], countries: [], genres: [] }` |
| GET | `/admin/v1/stations` | Array of station objects (see `src/types/admin.ts`) |
| GET | `/admin/v1/stations/:id` | Single station |
| POST | `/admin/v1/stations` | Create |
| PUT | `/admin/v1/stations/:id` | Update |
| GET | `/admin/v1/users` | Read-only user list |
Until Vapor implements these, use **“Already have a bearer token?”** on the login screen if you only need to probe an API, or expect empty lists / warning banners.
## Production build
```bash
npm run build
```
Output in **`dist/`** — serve as static files (nginx, S3, etc.).
### Docker (from monorepo root)
```bash
docker compose --profile compile run --rm admin-compile
```
Builds into **`admin-web/dist/`** inside a Node container (see root **`docker-compose.yml`**). Optional repo-root **`.env`** can set **`VITE_ADMIN_API_BASE_URL`** for that build.
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RadioStore Admin</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2163
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "radiostore-admin",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"bootstrap": "^5.3.3",
"react": "^18.3.1",
"react-bootstrap": "^2.10.5",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"typescript": "~5.6.3",
"vite": "^5.4.11"
}
}
+30
View File
@@ -0,0 +1,30 @@
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import { AuthProvider } from './auth/AuthContext';
import { ProtectedRoute } from './auth/ProtectedRoute';
import { AdminLayout } from './layout/AdminLayout';
import { DashboardPage } from './pages/DashboardPage';
import { LoginPage } from './pages/LoginPage';
import { StationEditPage } from './pages/StationEditPage';
import { StationsPage } from './pages/StationsPage';
import { UsersPage } from './pages/UsersPage';
export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}>
<Route path="/" element={<AdminLayout />}>
<Route index element={<DashboardPage />} />
<Route path="stations" element={<StationsPage />} />
<Route path="stations/:stationId" element={<StationEditPage />} />
<Route path="users" element={<UsersPage />} />
</Route>
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { adminFetchJson } from './client';
import type { AdminStation, AdminUser, TaxonomyResponse } from '../types/admin';
/**
* Expected Vapor admin routes (`docs/plan.md`): `/admin/v1/...`
* Adjust paths here when the backend is implemented.
*/
export interface LoginResponse {
access_token?: string;
token?: string;
}
export async function login(email: string, password: string): Promise<string> {
const json = await adminFetchJson<LoginResponse>('/admin/v1/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const token = json.access_token ?? json.token;
if (!token) {
throw new Error('Login response missing access_token');
}
return token;
}
export async function fetchTaxonomy(): Promise<TaxonomyResponse> {
return adminFetchJson<TaxonomyResponse>('/admin/v1/taxonomy');
}
export async function fetchStations(): Promise<AdminStation[]> {
return adminFetchJson<AdminStation[]>('/admin/v1/stations');
}
export async function fetchStation(id: string): Promise<AdminStation> {
return adminFetchJson<AdminStation>(`/admin/v1/stations/${encodeURIComponent(id)}`);
}
export async function createStation(body: Omit<AdminStation, 'id'>): Promise<AdminStation> {
return adminFetchJson<AdminStation>('/admin/v1/stations', {
method: 'POST',
body: JSON.stringify(body),
});
}
export async function updateStation(id: string, body: Partial<AdminStation>): Promise<AdminStation> {
return adminFetchJson<AdminStation>(`/admin/v1/stations/${encodeURIComponent(id)}`, {
method: 'PUT',
body: JSON.stringify(body),
});
}
export async function fetchUsers(): Promise<AdminUser[]> {
return adminFetchJson<AdminUser[]>('/admin/v1/users');
}
+57
View File
@@ -0,0 +1,57 @@
const TOKEN_KEY = 'radiostore_admin_token';
export function getAdminApiBaseUrl(): string {
const raw = import.meta.env.VITE_ADMIN_API_BASE_URL ?? '';
return raw.replace(/\/+$/, '');
}
export function getStoredToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setStoredToken(token: string | null): void {
if (token === null || token === '') {
localStorage.removeItem(TOKEN_KEY);
return;
}
localStorage.setItem(TOKEN_KEY, token);
}
export class ApiError extends Error {
readonly status: number;
readonly bodyText: string | null;
constructor(status: number, message: string, bodyText: string | null) {
super(message);
this.name = 'ApiError';
this.status = status;
this.bodyText = bodyText;
}
}
export async function adminFetch(path: string, init: RequestInit = {}): Promise<Response> {
const base = getAdminApiBaseUrl();
const url = `${base}${path.startsWith('/') ? path : `/${path}`}`;
const headers = new Headers(init.headers);
headers.set('Accept', 'application/json');
if (init.body !== undefined && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const token = getStoredToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
return fetch(url, { ...init, headers });
}
export async function adminFetchJson<T>(path: string, init: RequestInit = {}): Promise<T> {
const res = await adminFetch(path, init);
const text = await res.text();
if (!res.ok) {
throw new ApiError(res.status, `HTTP ${res.status}`, text || null);
}
if (!text) {
return undefined as T;
}
return JSON.parse(text) as T;
}
+48
View File
@@ -0,0 +1,48 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from 'react';
import { getStoredToken, setStoredToken } from '../api/client';
export interface AuthContextValue {
token: string | null;
/** Persist token from login API or manual paste (local dev). */
signInWithToken: (token: string) => void;
signOut: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(() => getStoredToken());
const signInWithToken = useCallback((t: string) => {
const trimmed = t.trim();
setStoredToken(trimmed);
setToken(trimmed);
}, []);
const signOut = useCallback(() => {
setStoredToken(null);
setToken(null);
}, []);
const value = useMemo(
() => ({ token, signInWithToken, signOut }),
[token, signInWithToken, signOut],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error('useAuth must be used within AuthProvider');
}
return ctx;
}
+13
View File
@@ -0,0 +1,13 @@
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import { useAuth } from './AuthContext';
export function ProtectedRoute() {
const { token } = useAuth();
const location = useLocation();
if (!token) {
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
return <Outlet />;
}
+21
View File
@@ -0,0 +1,21 @@
:root {
color-scheme: light dark;
}
body {
margin: 0;
min-height: 100vh;
font-family:
system-ui,
-apple-system,
Segoe UI,
Roboto,
Helvetica,
Arial,
sans-serif;
}
main.admin-main {
padding-top: 1rem;
padding-bottom: 2rem;
}
+41
View File
@@ -0,0 +1,41 @@
import { Container, Nav, Navbar } from 'react-bootstrap';
import { Link, NavLink, Outlet } from 'react-router-dom';
import { useAuth } from '../auth/AuthContext';
export function AdminLayout() {
const { signOut } = useAuth();
return (
<>
<Navbar bg="dark" variant="dark" expand="lg" className="mb-3">
<Container fluid>
<Navbar.Brand as={Link} to="/">
RadioStore Admin
</Navbar.Brand>
<Navbar.Toggle aria-controls="admin-nav" />
<Navbar.Collapse id="admin-nav">
<Nav className="me-auto">
<Nav.Link as={NavLink} to="/" end>
Dashboard
</Nav.Link>
<Nav.Link as={NavLink} to="/stations">
Stations
</Nav.Link>
<Nav.Link as={NavLink} to="/users">
Users
</Nav.Link>
</Nav>
<Nav>
<Nav.Link onClick={signOut}>Sign out</Nav.Link>
</Nav>
</Navbar.Collapse>
</Container>
</Navbar>
<main className="admin-main">
<Container fluid>
<Outlet />
</Container>
</main>
</>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import 'bootstrap/dist/css/bootstrap.min.css';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+38
View File
@@ -0,0 +1,38 @@
import { Card, Col, Row } from 'react-bootstrap';
import { Link } from 'react-router-dom';
export function DashboardPage() {
return (
<div>
<h1 className="h3 mb-4">Dashboard</h1>
<Row className="g-3">
<Col md={6}>
<Card className="h-100 shadow-sm">
<Card.Body>
<Card.Title>Stations</Card.Title>
<Card.Text className="text-muted">
CRUD stations, hide from catalog, assign geo / country / genre, set minimum age.
</Card.Text>
<Link className="btn btn-primary btn-sm" to="/stations">
Open stations
</Link>
</Card.Body>
</Card>
</Col>
<Col md={6}>
<Card className="h-100 shadow-sm">
<Card.Body>
<Card.Title>Users</Card.Title>
<Card.Text className="text-muted">
Read-only list for support (Apple subject, identifiers).
</Card.Text>
<Link className="btn btn-outline-primary btn-sm" to="/users">
Open users
</Link>
</Card.Body>
</Card>
</Col>
</Row>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
import { useState, type FormEvent } from 'react';
import { Accordion, Alert, Button, Card, Form, Spinner } from 'react-bootstrap';
import { Navigate, useNavigate } from 'react-router-dom';
import { login } from '../api/adminApi';
import { ApiError } from '../api/client';
import { useAuth } from '../auth/AuthContext';
export function LoginPage() {
const navigate = useNavigate();
const { token, signInWithToken } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [tokenPaste, setTokenPaste] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setBusy(true);
try {
const accessToken = await login(email, password);
signInWithToken(accessToken);
navigate('/', { replace: true });
} catch (err) {
if (err instanceof ApiError) {
setError(`${err.message}${err.bodyText ? `: ${err.bodyText}` : ''}`);
} else if (err instanceof Error) {
setError(err.message);
} else {
setError('Sign-in failed');
}
} finally {
setBusy(false);
}
}
function onPasteToken(e: FormEvent) {
e.preventDefault();
setError(null);
if (!tokenPaste.trim()) {
setError('Paste a bearer token first.');
return;
}
signInWithToken(tokenPaste);
navigate('/', { replace: true });
}
if (token) {
return <Navigate to="/" replace />;
}
return (
<div className="mx-auto" style={{ maxWidth: 440, padding: '2rem 1rem' }}>
<Card className="shadow-sm">
<Card.Body>
<Card.Title className="mb-3">RadioStore Admin</Card.Title>
<Card.Subtitle className="mb-4 text-muted">
Sign in against the Vapor admin API (`/admin/v1`).
</Card.Subtitle>
{error && (
<Alert variant="danger" className="small">
{error}
</Alert>
)}
<Form onSubmit={onSubmit}>
<Form.Group className="mb-3" controlId="login-email">
<Form.Label>Email</Form.Label>
<Form.Control
type="email"
autoComplete="username"
value={email}
onChange={(ev) => setEmail(ev.target.value)}
required
/>
</Form.Group>
<Form.Group className="mb-3" controlId="login-password">
<Form.Label>Password</Form.Label>
<Form.Control
type="password"
autoComplete="current-password"
value={password}
onChange={(ev) => setPassword(ev.target.value)}
required
/>
</Form.Group>
<Button variant="primary" type="submit" className="w-100" disabled={busy}>
{busy ? (
<>
<Spinner animation="border" size="sm" className="me-2" />
Signing in
</>
) : (
'Sign in'
)}
</Button>
</Form>
<Accordion className="mt-4">
<Accordion.Item eventKey="0">
<Accordion.Header>Already have a bearer token?</Accordion.Header>
<Accordion.Body>
<Form onSubmit={onPasteToken}>
<Form.Group className="mb-2">
<Form.Control
as="textarea"
rows={3}
placeholder="Paste JWT / API token"
value={tokenPaste}
onChange={(ev) => setTokenPaste(ev.target.value)}
/>
</Form.Group>
<Button type="submit" variant="outline-secondary" size="sm">
Use token
</Button>
</Form>
</Accordion.Body>
</Accordion.Item>
</Accordion>
</Card.Body>
</Card>
</div>
);
}
+315
View File
@@ -0,0 +1,315 @@
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { Alert, Button, Form, Spinner } from 'react-bootstrap';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { createStation, fetchStation, fetchTaxonomy, updateStation } from '../api/adminApi';
import { ApiError } from '../api/client';
import type { AdminStation } from '../types/admin';
const emptyDraft = (): Omit<AdminStation, 'id'> => ({
title: '',
image_url: null,
stream_url: '',
stream_kind: 'mp3',
hidden: false,
min_age: null,
geo_ids: [],
country_ids: [],
genre_ids: [],
});
export function StationEditPage() {
const { stationId } = useParams();
const navigate = useNavigate();
const isCreate = stationId === 'new';
const [taxonomyError, setTaxonomyError] = useState<string | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [busy, setBusy] = useState(!isCreate);
const [saving, setSaving] = useState(false);
const [draft, setDraft] = useState<Omit<AdminStation, 'id'>>(emptyDraft);
const [geoOptions, setGeoOptions] = useState<{ id: string; name: string }[]>([]);
const [countryOptions, setCountryOptions] = useState<{ id: string; name: string; geo_id: string }[]>([]);
const [genreOptions, setGenreOptions] = useState<{ id: string; title: string }[]>([]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const tax = await fetchTaxonomy();
if (cancelled) return;
setGeoOptions(tax.geos);
setCountryOptions(tax.countries);
setGenreOptions(tax.genres);
} catch (err) {
if (cancelled) return;
if (err instanceof ApiError) {
setTaxonomyError(`${err.message}`);
} else {
setTaxonomyError('Could not load taxonomy (optional until backend exists).');
}
}
})();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!stationId || isCreate) {
setBusy(false);
return;
}
let cancelled = false;
(async () => {
try {
const s = await fetchStation(stationId);
if (cancelled) return;
setDraft({
title: s.title,
image_url: s.image_url,
stream_url: s.stream_url,
stream_kind: s.stream_kind,
hidden: s.hidden,
min_age: s.min_age,
geo_ids: s.geo_ids,
country_ids: s.country_ids,
genre_ids: s.genre_ids,
});
} catch (err) {
if (!cancelled) {
if (err instanceof ApiError) {
setLoadError(`${err.message}`);
} else if (err instanceof Error) {
setLoadError(err.message);
} else {
setLoadError('Failed to load station');
}
}
} finally {
if (!cancelled) {
setBusy(false);
}
}
})();
return () => {
cancelled = true;
};
}, [stationId, isCreate]);
const countriesFiltered = useMemo(() => {
if (draft.geo_ids.length === 0) {
return countryOptions;
}
const set = new Set(draft.geo_ids);
return countryOptions.filter((c) => set.has(c.geo_id));
}, [countryOptions, draft.geo_ids]);
async function onSave(e: FormEvent) {
e.preventDefault();
setSaveError(null);
setSaving(true);
try {
if (isCreate) {
await createStation(draft);
} else if (stationId) {
await updateStation(stationId, draft);
}
navigate('/stations');
} catch (err) {
if (err instanceof ApiError) {
setSaveError(`${err.message}${err.bodyText ? `: ${err.bodyText.slice(0, 400)}` : ''}`);
} else if (err instanceof Error) {
setSaveError(err.message);
} else {
setSaveError('Save failed');
}
} finally {
setSaving(false);
}
}
function toggleId(list: string[], id: string, checked: boolean): string[] {
const set = new Set(list);
if (checked) {
set.add(id);
} else {
set.delete(id);
}
return [...set];
}
if (busy) {
return (
<Spinner animation="border" className="mt-3">
<span className="visually-hidden">Loading</span>
</Spinner>
);
}
return (
<div style={{ maxWidth: 720 }}>
<div className="d-flex justify-content-between align-items-center mb-3 gap-2 flex-wrap">
<h1 className="h3 mb-0">{isCreate ? 'New station' : 'Edit station'}</h1>
<Button variant="outline-secondary" size="sm" as={Link} to="/stations">
Back to list
</Button>
</div>
{loadError && <Alert variant="danger">{loadError}</Alert>}
{taxonomyError && (
<Alert variant="info" className="small">
Taxonomy not loaded: {taxonomyError}. You can still edit URLs and flags; geo/country/genre pickers stay empty until{' '}
<code>GET /admin/v1/taxonomy</code> exists.
</Alert>
)}
{saveError && <Alert variant="danger">{saveError}</Alert>}
<Form onSubmit={onSave}>
<Form.Group className="mb-3">
<Form.Label>Title</Form.Label>
<Form.Control
value={draft.title}
onChange={(ev) => setDraft({ ...draft, title: ev.target.value })}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Image URL</Form.Label>
<Form.Control
value={draft.image_url ?? ''}
onChange={(ev) =>
setDraft({
...draft,
image_url: ev.target.value === '' ? null : ev.target.value,
})
}
placeholder="https://…"
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Stream URL</Form.Label>
<Form.Control
value={draft.stream_url}
onChange={(ev) => setDraft({ ...draft, stream_url: ev.target.value })}
required
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Stream kind</Form.Label>
<Form.Select
value={draft.stream_kind}
onChange={(ev) =>
setDraft({
...draft,
stream_kind: ev.target.value as AdminStation['stream_kind'],
})
}
>
<option value="mp3">mp3</option>
<option value="m3u8">m3u8 (HLS)</option>
</Form.Select>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Minimum age (optional)</Form.Label>
<Form.Control
type="number"
min={0}
value={draft.min_age ?? ''}
onChange={(ev) => {
const v = ev.target.value;
setDraft({
...draft,
min_age: v === '' ? null : Number.parseInt(v, 10),
});
}}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Check
type="switch"
id="hidden-switch"
label="Hidden from public catalog"
checked={draft.hidden}
onChange={(ev) => setDraft({ ...draft, hidden: ev.target.checked })}
/>
</Form.Group>
<fieldset className="mb-3 border rounded p-3">
<legend className="float-none w-auto px-2 fs-6">Regions</legend>
<div className="d-flex flex-column gap-1">
{geoOptions.map((g) => (
<Form.Check
key={g.id}
id={`geo-${g.id}`}
label={g.name}
checked={draft.geo_ids.includes(g.id)}
onChange={(ev) =>
setDraft({
...draft,
geo_ids: toggleId(draft.geo_ids, g.id, ev.target.checked),
})
}
/>
))}
{geoOptions.length === 0 && <span className="text-muted small">No regions loaded.</span>}
</div>
</fieldset>
<fieldset className="mb-3 border rounded p-3">
<legend className="float-none w-auto px-2 fs-6">Countries</legend>
<div className="d-flex flex-column gap-1">
{countriesFiltered.map((c) => (
<Form.Check
key={c.id}
id={`country-${c.id}`}
label={c.name}
checked={draft.country_ids.includes(c.id)}
onChange={(ev) =>
setDraft({
...draft,
country_ids: toggleId(draft.country_ids, c.id, ev.target.checked),
})
}
/>
))}
{countriesFiltered.length === 0 && (
<span className="text-muted small">No countries loaded or none match selected regions.</span>
)}
</div>
</fieldset>
<fieldset className="mb-4 border rounded p-3">
<legend className="float-none w-auto px-2 fs-6">Genres</legend>
<div className="d-flex flex-column gap-1">
{genreOptions.map((g) => (
<Form.Check
key={g.id}
id={`genre-${g.id}`}
label={g.title}
checked={draft.genre_ids.includes(g.id)}
onChange={(ev) =>
setDraft({
...draft,
genre_ids: toggleId(draft.genre_ids, g.id, ev.target.checked),
})
}
/>
))}
{genreOptions.length === 0 && <span className="text-muted small">No genres loaded.</span>}
</div>
</fieldset>
<Button type="submit" variant="primary" disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</Button>
</Form>
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useState } from 'react';
import { Alert, Badge, Button, Spinner, Table } from 'react-bootstrap';
import { Link } from 'react-router-dom';
import { fetchStations } from '../api/adminApi';
import { ApiError } from '../api/client';
import type { AdminStation } from '../types/admin';
export function StationsPage() {
const [rows, setRows] = useState<AdminStation[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const list = await fetchStations();
if (!cancelled) {
setRows(list);
}
} catch (err) {
if (!cancelled) {
setRows([]);
if (err instanceof ApiError) {
setError(`${err.message}${err.bodyText ? `${err.bodyText.slice(0, 200)}` : ''}`);
} else if (err instanceof Error) {
setError(err.message);
} else {
setError('Failed to load stations');
}
}
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<div>
<div className="d-flex justify-content-between align-items-center mb-3 gap-2 flex-wrap">
<h1 className="h3 mb-0">Stations</h1>
<Button as={Link} to="/stations/new" variant="success" size="sm">
New station
</Button>
</div>
{error && (
<Alert variant="warning" className="small">
<strong>API:</strong> {error}. Set <code>VITE_ADMIN_API_BASE_URL</code> and ensure the Vapor server exposes{' '}
<code>GET /admin/v1/stations</code>.
</Alert>
)}
{rows === null ? (
<Spinner animation="border" role="status" className="mt-3">
<span className="visually-hidden">Loading</span>
</Spinner>
) : rows.length === 0 && !error ? (
<Alert variant="secondary">No stations returned.</Alert>
) : (
<Table striped bordered hover responsive size="sm" className="bg-white">
<thead>
<tr>
<th>Title</th>
<th>Kind</th>
<th>Visibility</th>
<th>Min age</th>
<th className="text-end">Actions</th>
</tr>
</thead>
<tbody>
{rows.map((s) => (
<tr key={s.id}>
<td>{s.title}</td>
<td>
<Badge bg="secondary">{s.stream_kind}</Badge>
</td>
<td>{s.hidden ? <Badge bg="danger">Hidden</Badge> : <Badge bg="success">Live</Badge>}</td>
<td>{s.min_age ?? '—'}</td>
<td className="text-end">
<Button as={Link} to={`/stations/${s.id}`} variant="outline-primary" size="sm">
Edit
</Button>
</td>
</tr>
))}
</tbody>
</Table>
)}
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, useState } from 'react';
import { Alert, Spinner, Table } from 'react-bootstrap';
import { fetchUsers } from '../api/adminApi';
import { ApiError } from '../api/client';
import type { AdminUser } from '../types/admin';
export function UsersPage() {
const [rows, setRows] = useState<AdminUser[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const list = await fetchUsers();
if (!cancelled) {
setRows(list);
}
} catch (err) {
if (!cancelled) {
setRows([]);
if (err instanceof ApiError) {
setError(`${err.message}${err.bodyText ? `${err.bodyText.slice(0, 200)}` : ''}`);
} else if (err instanceof Error) {
setError(err.message);
} else {
setError('Failed to load users');
}
}
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<div>
<h1 className="h3 mb-3">Users</h1>
<p className="text-muted small">
Read-only directory for support. Writes remain server-side only.
</p>
{error && (
<Alert variant="warning" className="small">
<strong>API:</strong> {error}. Expected <code>GET /admin/v1/users</code>.
</Alert>
)}
{rows === null ? (
<Spinner animation="border" role="status" className="mt-3">
<span className="visually-hidden">Loading</span>
</Spinner>
) : rows.length === 0 && !error ? (
<Alert variant="secondary">No users returned.</Alert>
) : (
<Table striped bordered hover responsive size="sm" className="bg-white">
<thead>
<tr>
<th>ID</th>
<th>Apple subject</th>
<th>Email</th>
<th>Created</th>
</tr>
</thead>
<tbody>
{rows.map((u) => (
<tr key={u.id}>
<td>
<code className="small">{u.id}</code>
</td>
<td>{u.apple_sub ?? '—'}</td>
<td>{u.email ?? '—'}</td>
<td>{u.created_at}</td>
</tr>
))}
</tbody>
</Table>
)}
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
/** Mirrors future public catalog taxonomy; admin assigns stations to these IDs. */
export interface GeoRegion {
id: string;
name: string;
}
export interface Country {
id: string;
name: string;
geo_id: string;
}
export interface Genre {
id: string;
title: string;
}
export interface TaxonomyResponse {
geos: GeoRegion[];
countries: Country[];
genres: Genre[];
}
export interface AdminStation {
id: string;
title: string;
image_url: string | null;
stream_url: string;
stream_kind: 'mp3' | 'm3u8';
hidden: boolean;
/** Minimum age for authenticated clients (null = no restriction). */
min_age: number | null;
geo_ids: string[];
country_ids: string[];
genre_ids: string[];
}
export interface AdminUser {
id: string;
apple_sub: string | null;
email: string | null;
created_at: string;
}
+9
View File
@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_ADMIN_API_BASE_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true
},
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler"
},
"include": ["vite.config.ts"]
}
+10
View File
@@ -0,0 +1,10 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
strictPort: true,
},
});
+267
View File
@@ -0,0 +1,267 @@
{
"originHash" : "5d801a9b13ecbd0e1df2b026784e0e29127971668daec6685da2beffd55aa5bb",
"pins" : [
{
"identity" : "async-http-client",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/async-http-client.git",
"state" : {
"revision" : "3a5b74a58782c3b4c1f0bc75e9b67b10c2494e8f",
"version" : "1.33.1"
}
},
{
"identity" : "async-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/async-kit.git",
"state" : {
"revision" : "6bbb83cbf9d886623a967a965c8fb1b73e6566f9",
"version" : "1.22.0"
}
},
{
"identity" : "console-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/console-kit.git",
"state" : {
"revision" : "32ad16dfc7677b927b225595ed18f3debb32f577",
"version" : "4.16.0"
}
},
{
"identity" : "multipart-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/multipart-kit.git",
"state" : {
"revision" : "3498e60218e6003894ff95192d756e238c01f44e",
"version" : "4.7.1"
}
},
{
"identity" : "routing-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/routing-kit.git",
"state" : {
"revision" : "1a10ccea61e4248effd23b6e814999ce7bdf0ee0",
"version" : "4.9.3"
}
},
{
"identity" : "swift-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-algorithms.git",
"state" : {
"revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023",
"version" : "1.2.1"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab",
"version" : "1.7.0"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms.git",
"state" : {
"revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272",
"version" : "1.1.3"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
"version" : "1.3.0"
}
},
{
"identity" : "swift-certificates",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-certificates.git",
"state" : {
"revision" : "bde8ca32a096825dfce37467137c903418c1893d",
"version" : "1.19.1"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "6675bc0ff86e61436e615df6fc5174e043e57924",
"version" : "1.4.1"
}
},
{
"identity" : "swift-configuration",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-configuration.git",
"state" : {
"revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9",
"version" : "1.2.0"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1",
"version" : "4.5.0"
}
},
{
"identity" : "swift-distributed-tracing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-distributed-tracing.git",
"state" : {
"revision" : "dc4030184203ffafbb2ec614352487235d747fe0",
"version" : "1.4.1"
}
},
{
"identity" : "swift-http-structured-headers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-structured-headers.git",
"state" : {
"revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47",
"version" : "1.7.0"
}
},
{
"identity" : "swift-http-types",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-types.git",
"state" : {
"revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca",
"version" : "1.5.1"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "5073617dac96330a486245e4c0179cb0a6fd2256",
"version" : "1.12.0"
}
},
{
"identity" : "swift-metrics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-metrics.git",
"state" : {
"revision" : "d51c8d13fa366eec807eedb4e37daa60ff5bfdd5",
"version" : "2.10.1"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "f71c8d2a5e74a2c6d11a0fbe324774b5d6084237",
"version" : "2.99.0"
}
},
{
"identity" : "swift-nio-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-extras.git",
"state" : {
"revision" : "5a48717e29f62cb8326d6d42e46b562ca93847a6",
"version" : "1.34.0"
}
},
{
"identity" : "swift-nio-http2",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-http2.git",
"state" : {
"revision" : "81cc18264f92cd307ff98430f89372711d4f6fe9",
"version" : "1.43.0"
}
},
{
"identity" : "swift-nio-ssl",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-ssl.git",
"state" : {
"revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da",
"version" : "2.37.0"
}
},
{
"identity" : "swift-nio-transport-services",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-transport-services.git",
"state" : {
"revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5",
"version" : "1.28.0"
}
},
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-numerics.git",
"state" : {
"revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
"version" : "1.1.1"
}
},
{
"identity" : "swift-service-context",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-service-context.git",
"state" : {
"revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29",
"version" : "1.3.0"
}
},
{
"identity" : "swift-service-lifecycle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/swift-service-lifecycle.git",
"state" : {
"revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a",
"version" : "2.11.0"
}
},
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df",
"version" : "1.6.4"
}
},
{
"identity" : "vapor",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/vapor.git",
"state" : {
"revision" : "cfd8f434843ac7850e2d97f46c1aa5ddb906cf1c",
"version" : "4.121.4"
}
},
{
"identity" : "websocket-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/websocket-kit.git",
"state" : {
"revision" : "90bbbdab3ede12c803cfbe91646f291c092517a3",
"version" : "2.16.2"
}
}
],
"version" : 3
}
+26
View File
@@ -0,0 +1,26 @@
// swift-tools-version: 5.10
import PackageDescription
let package = Package(
name: "RadioStoreBackend",
platforms: [
.macOS(.v13),
],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.92.0"),
],
targets: [
.executableTarget(
name: "Run",
dependencies: [
.target(name: "App"),
]
),
.target(
name: "App",
dependencies: [
.product(name: "Vapor", package: "vapor"),
]
),
]
)
+19
View File
@@ -0,0 +1,19 @@
# RadioStore backend
Minimal **[Vapor 4](https://docs.vapor.codes)** scaffold (`Sources/App`, `Sources/Run`): **`GET /health`** and **`GET /api/v1/status`**.
Local release build:
```bash
swift build -c release --product Run
```
Run the produced binary from `.build/<triple>/release/Run` (triple depends on OS/CPU).
Docker compile (writes under `./backend/.build` using the Linux toolchain):
```bash
docker compose --profile compile run --rm backend-compile
```
See **`docker-compose.yml`** at the repo root and **`docs/DEVELOPMENT.md`**.
+8
View File
@@ -0,0 +1,8 @@
import Vapor
public func configure(_ app: Application) throws {
app.http.server.configuration.hostname = Environment.get("BIND_HOST") ?? "0.0.0.0"
app.http.server.configuration.port = Environment.get("BIND_PORT").flatMap(Int.init(_:)) ?? 8080
try routes(app)
}
+11
View File
@@ -0,0 +1,11 @@
import Vapor
func routes(_ app: Application) throws {
app.get("health") { _ in
"ok"
}
app.get("api", "v1", "status") { _ in
"radiostore-backend-scaffold"
}
}
+13
View File
@@ -0,0 +1,13 @@
import App
import Vapor
var env = try Environment.detect()
try LoggingSystem.bootstrap(from: &env)
let app = Application(env)
defer { app.shutdown() }
try configure(app)
try app.run()
+36
View File
@@ -0,0 +1,36 @@
# RadioStore — compile backend (Swift/Vapor) and admin panel (Vite/React) in containers.
#
# Prerequisites: Docker with Compose v2+.
#
# Usage (repo root):
# docker compose --profile compile run --rm backend-compile
# docker compose --profile compile run --rm admin-compile
#
# Or both:
# ./scripts/docker-compile-all.sh
#
# Optional: `.env` in repo root (see `.env.example`) sets `VITE_ADMIN_API_BASE_URL` for the admin build.
#
# Notes:
# - First `backend-compile` run pulls `swift:5.10-jammy` (large image).
# - Release binary path follows SPM layout (inside `./backend/.build`, varies by platform triple).
# - Admin output is always `./admin-web/dist/`.
services:
backend-compile:
image: swift:5.10-jammy
profiles: [compile]
working_dir: /src
volumes:
- ./backend:/src
command: ["swift", "build", "-c", "release", "--product", "Run"]
admin-compile:
image: node:20-bookworm-slim
profiles: [compile]
working_dir: /app
volumes:
- ./admin-web:/app
environment:
VITE_ADMIN_API_BASE_URL: ${VITE_ADMIN_API_BASE_URL:-http://localhost:8080}
command: ["sh", "-c", "npm install && npm run build"]
+11 -8
View File
@@ -24,32 +24,35 @@ A **radio station listing** product with three parts:
- `docs/plan.md` — implementation plan and phases.
- `Apps/RadioStoreTV/RadioStoreTV.xcodeproj` — tvOS app; links local package `Packages/App` (`RadioStoreApp`). Open this project in Xcode to run on simulator or device.
- `Packages/Core`, `Packages/API`, `Packages/Player`, `Packages/App` — SPM modules (`RadioStoreApp` depends on `Core` + `Player` + **`RadioStoreAPI`** from folder `Packages/API`; `RadioStoreAPI` depends on `Core`; `Player` depends on `Core`). Mock stream: `MockRadioStation.eftelingLive`.
- `backend/`Vapor CMS/API (`Sources/App`, `Public`, `Resources`).
- `admin-web/` — React static client (`public`, `src`).
- `backend/`Minimal **Vapor 4** scaffold (`Sources/App`, `Sources/Run`, `Package.resolved`); [`backend/README.md`](../backend/README.md).
- `docker-compose.yml` — profile **`compile`**: **`backend-compile`**, **`admin-compile`**; convenience script [`scripts/docker-compile-all.sh`](../scripts/docker-compile-all.sh).
- `admin-web/` — React admin ([`admin-web/README.md`](../admin-web/README.md)); Vite dev server on port **5173**.
## Session snapshot (resume here)
**Last focus:** Process-environment **file mocks** for REST (`RadioStoreAPIServing`, bundled `MockAPI/*.json`, optional injected folder).
**Last focus:** **Docker Compose `compile` profile** — containerized **Swift** release build for `backend/` + **`npm run build`** for `admin-web/`; minimal Vapor scaffold checked in.
**Already implemented**
- **`Packages/Core`** — `RadioStation`, `StreamKind` (`mp3` / `m3u8`), `MockRadioStation.eftelingLive` (test URL: `media-edge2.hostin.live``efteling.mp3` …). `RadioStation` is **`Hashable`** for `NavigationStack` values. Platforms: tvOS **17** + macOS **14** (for SPM tests).
- **`Packages/API`** (SwiftPM package name **`RadioStoreAPI`**, product **`RadioStoreAPI`**) — `RadioStoreAPIServing`, **`RadioStoreAPIClient`** (HTTP), **`FileMockRadioStoreAPIClient`** (JSON on disk, no network). DTOs + explicit **`CodingKeys`** (`snake_case` JSON). **`RadioStoreAPIError.fixtureMissing`**. `swift test` from `Packages/API`.
- **`Packages/Player`** — `RadioPlayerController` (`load`, `play`, `pause`, `togglePlayback`, `volume` on `AVPlayer`); `RadioPlayerView` with Play/Pause and **Quieter/Louder** (no `Slider` on tvOS).
- **`Packages/Player`** — `RadioPlayerController`; **`RadioPlayerView`** — Siri Remote **Play/Pause** + touch surface **up/down** for mix volume (**no on-screen transport buttons**).
- **`Packages/App`** — **`RadioStoreProcessEnvironment`** reads **`ProcessInfo.processInfo.environment`**: **`RADIOSTORE_USE_FILE_MOCKS`** (truthy) → **`FileMockRadioStoreAPIClient`**; **`RADIOSTORE_MOCK_FIXTURES_DIR`** → custom fixture folder; else **`RADIOSTORE_API_BASE_URL`** → live **`RadioStoreAPIClient`**; else no API (**`CatalogMockData`**). Bundled **`Resources/MockAPI/`** (`catalog.json`, `station.json`, `device_register.json`, `auth_apple.json`, `favorites.json`). **`RadioStoreRootView(apiConfiguration:bundle:)`** — explicit config skips env routing.
- **`backend/`** — Minimal **Vapor** app (`GET /health`, **`GET /api/v1/status`**); **`docker compose --profile compile run --rm backend-compile`** uses **`swift:5.10-jammy`** (large first-time pull). **`backend/Package.resolved`** pinned for SPM.
- **`admin-web/`** — **Vite + React + TypeScript + React Router + React Bootstrap**: login (email/password or pasted bearer token), dashboard, stations CRUD UI, read-only users table; **`VITE_ADMIN_API_BASE_URL`**. Docker: **`docker compose --profile compile run --rm admin-compile`** → **`admin-web/dist/`**.
- **`Apps/RadioStoreTV/`** — Shared **Run** scheme lists env vars (**disabled** by default): enable **`RADIOSTORE_USE_FILE_MOCKS`** and optionally **`RADIOSTORE_MOCK_FIXTURES_DIR`** (sample path: repo **`Packages/App/Sources/RadioStoreApp/Resources/MockAPI`**) to iterate JSON without rebuilding resource copies.
- **Build check:** from `Apps/RadioStoreTV`, `xcodebuild -scheme RadioStoreTV -destination 'platform=tvOS Simulator,name=Apple TV' build` succeeds locally.
**Not started / placeholders**
- **SwiftData**, **live backend** matching DTOs, **Sign in with Apple** (live), **favorites / history** sync.
- **`backend/`**, **`admin-web/`** — still empty scaffolding.
- **Full REST surface** — Public **`/api/v1/*`**, admin **`/admin/v1/*`**, Postgres/Fluent, auth — still to implement per **`docs/plan.md`** (admin UI + **`RadioStoreAPI`** already expect routes).
## Immediate next steps when continuing
1. **Backend:** Implement `GET /api/v1/catalog` matching `CatalogPayload` (regions → countries → genres → stations).
2. **tvOS:** SwiftData cache + repository that prefers API then persists; wire **device/register**, tokens, favorites/history when endpoints exist.
3. **Infra:** Docker Compose Postgres **18.2** + Vapor skeleton (`docs/plan.md` Phase 01).
1. **Backend:** Vapor + Postgres (`docs/plan.md` Phase 01): implement public **`/api/v1/*`** and admin **`/admin/v1/*`** to match **`RadioStoreAPI`** + **`admin-web`** contracts.
2. **tvOS:** SwiftData cache + repository; wire device/register and favorites/history when endpoints exist.
3. **Admin:** Harden auth (API keys / SSO per product decision); pagination and optimistic UX once APIs exist.
## Key product rules
+46 -4
View File
@@ -1,6 +1,6 @@
# RadioStore — development guide
This document describes the **implemented** Apple TV client and Swift packages, how they fit together, and how to run and debug them quickly. Roadmap and product intent remain in [`plan.md`](plan.md); compact editor handoff notes live in [`CURSOR_SESSION_CONTEXT.md`](CURSOR_SESSION_CONTEXT.md).
This document describes the **implemented** Apple TV client, Swift packages, and **React admin web app**, how they fit together, and how to run them quickly. Roadmap and product intent remain in [`plan.md`](plan.md); compact editor handoff notes live in [`CURSOR_SESSION_CONTEXT.md`](CURSOR_SESSION_CONTEXT.md).
---
@@ -11,9 +11,11 @@ This document describes the **implemented** Apple TV client and Swift packages,
| [`Apps/RadioStoreTV/`](../Apps/RadioStoreTV/) | Xcode tvOS **17** application. Depends on the local SPM package [`Packages/App`](../Packages/App/) (`RadioStoreApp`). |
| [`Packages/Core/`](../Packages/Core/) | Shared domain types (`RadioStation`, `StreamKind`, mock sample station). |
| [`Packages/API/`](../Packages/API/) | Swift package **`RadioStoreAPI`**: REST client, DTOs, file-based mock client, `RadioStoreAPIServing` protocol. |
| [`Packages/Player/`](../Packages/Player/) | Playback: `RadioPlayerController` + `RadioPlayerView` (tvOS-friendly controls; no `Slider`). |
| [`Packages/Player/`](../Packages/Player/) | Playback: `RadioPlayerController` + `RadioPlayerView` (**Siri Remote** Play/Pause and surface up/down for mix volume). |
| [`Packages/App/`](../Packages/App/) | Swift package **`RadioStoreApp`**: splash, tab shell, browse UI, profile placeholder, environment-driven API wiring, bundled JSON fixtures. |
| [`backend/`](../backend/), [`admin-web/`](../admin-web/) | Planned Vapor + React admin per [`plan.md`](plan.md); not yet the focus of this guide. |
| [`admin-web/`](../admin-web/) | **React** admin (Vite): login, stations, users — section **8** below and [`admin-web/README.md`](../admin-web/README.md). |
| [`backend/`](../backend/) | Minimal **Vapor 4** scaffold + **`Package.resolved`** — [`backend/README.md`](../backend/README.md). Full `/api/v1` + `/admin/v1` still per [`plan.md`](plan.md). |
| [`docker-compose.yml`](../docker-compose.yml) | Profile **`compile`**: compile **backend** (`swift:5.10-jammy`) and **admin** (`node:20-bookworm-slim`); [`scripts/docker-compile-all.sh`](../scripts/docker-compile-all.sh). |
---
@@ -196,9 +198,49 @@ cd Apps/RadioStoreTV && xcodebuild \
cd Packages/API && swift test
```
### 7.7 Admin panel (React)
See **[`admin-web/README.md`](../admin-web/README.md)**. Short version:
```bash
cd admin-web && cp .env.example .env && npm install && npm run dev
```
Configure **`VITE_ADMIN_API_BASE_URL`** and CORS on the server. Screens: **Login** (email/password or pasted bearer token), **Dashboard**, **Stations** (list + edit/create), **Users** (read-only).
### 7.8 Docker: compile backend + admin
[**`docker-compose.yml`**](../docker-compose.yml) defines profile **`compile`** (nothing starts by default). Bind-mounts `./backend` and `./admin-web`, writes **`backend/.build`** (Swift PM layout inside that folder) and **`admin-web/dist`**.
```bash
# Optional: repo-root `.env` for `VITE_ADMIN_API_BASE_URL` during admin production build (see `.env.example`).
docker compose --profile compile run --rm backend-compile
docker compose --profile compile run --rm admin-compile
```
Or: **`scripts/docker-compile-all.sh`**.
The **`swift:5.10-jammy`** image used for the backend is large on first pull.
---
## 8. Further reading
## 8. Admin web (`admin-web/`)
Stack matches [`plan.md`](plan.md) §6: **Vite**, **React 18**, **React Router 6**, **Bootstrap 5** + **react-bootstrap**.
| Area | Location |
|------|----------|
| Routes / shell | [`admin-web/src/App.tsx`](../admin-web/src/App.tsx), [`AdminLayout.tsx`](../admin-web/src/layout/AdminLayout.tsx) |
| Auth (stored bearer token) | [`AuthContext.tsx`](../admin-web/src/auth/AuthContext.tsx), [`ProtectedRoute.tsx`](../admin-web/src/auth/ProtectedRoute.tsx) |
| HTTP client | [`api/client.ts`](../admin-web/src/api/client.ts) (`Authorization: Bearer …`) |
| Typed admin calls | [`api/adminApi.ts`](../admin-web/src/api/adminApi.ts) |
| Shared TS shapes | [`types/admin.ts`](../admin-web/src/types/admin.ts) |
JSON field names use **`snake_case`** (`image_url`, `stream_url`, …) so payloads align naturally with a future **Vapor** API.
---
## 9. Further reading
- [**Implementation plan**](plan.md) — phases, MVP definition, backend/admin direction.
- [**Cursor session context**](CURSOR_SESSION_CONTEXT.md) — short-lived snapshot of last focus and constraints for tooling.
+2 -1
View File
@@ -2,7 +2,8 @@
| Document | Purpose |
|----------|---------|
| [**Development guide**](DEVELOPMENT.md) | tvOS application layout, SPM modules, API layer (`RadioStoreAPI`), debug fixtures via environment variables, build/test commands, and a short **quick start** for day-to-day work. |
| [**Development guide**](DEVELOPMENT.md) | tvOS app, SPM modules, **`RadioStoreAPI`**, env mocks, **React admin** (`admin-web`), build/test, quick start. |
| [**Admin web (`admin-web`)**](../admin-web/README.md) | Vite/React panel; env **`VITE_ADMIN_API_BASE_URL`** (overview also in [**DEVELOPMENT.md**](DEVELOPMENT.md)). |
| [**Implementation plan**](plan.md) | Product scope, domain model, REST outline, phased roadmap, open decisions (authoritative roadmap). |
| [**Cursor session context**](CURSOR_SESSION_CONTEXT.md) | Compact handoff for AI/editor sessions: stack constraints, repo layout, latest snapshot of what is implemented. |
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
echo "==> Backend (Swift release binary → backend/.build/…)"
docker compose --profile compile run --rm backend-compile
echo "==> Admin (Vite → admin-web/dist/)"
docker compose --profile compile run --rm admin-compile
echo "Done."