Docker build
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
**/.DS_Store
|
||||
**/node_modules
|
||||
admin-web/dist
|
||||
backend/.build
|
||||
**/DerivedData
|
||||
**/*.xcuserstate
|
||||
Packages/**/.build
|
||||
Apps/**/build
|
||||
@@ -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
|
||||
@@ -15,6 +15,7 @@ DerivedData/
|
||||
.build/
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
**/xcschememanagement.plist
|
||||
|
||||
# Vapor / Swift backend
|
||||
backend/.build/
|
||||
|
||||
+1
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
Generated
+2163
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_ADMIN_API_BASE_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"),
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -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`**.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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"]
|
||||
@@ -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 0–1).
|
||||
1. **Backend:** Vapor + Postgres (`docs/plan.md` Phase 0–1): 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
@@ -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
@@ -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. |
|
||||
|
||||
|
||||
Executable
+12
@@ -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."
|
||||
Reference in New Issue
Block a user