initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:14:50 +02:00
commit 1253955070
75 changed files with 13972 additions and 0 deletions

12
web/index.html Normal file
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>Go-Places Explorer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>

1227
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

27
web/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "web",
"version": "1.0.0",
"description": "React client for the go-places API",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"@vitejs/plugin-react": "^6.1.1",
"axios": "^1.20.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"vite": "^8.3.2"
},
"devDependencies": {
"@types/react-dom": "^19.3.0",
"typescript": "^5.9.3"
}
}

402
web/src/App.tsx Normal file
View file

@ -0,0 +1,402 @@
/**
* Go Places explorer.
*
* Lists the places the API knows about, lets one be opened to see its pictures
* and the pictures linked to them as having been taken at the same spot, and
* creates a new place with an optional first picture.
*/
import React, {useCallback, useEffect, useState} from 'react'
import {
ApiError,
createPlace,
getPlace,
getToken,
listPlaces,
resolveMedia,
setToken,
uploadImage,
} from './api'
import type {Image, Place} from './types'
import './styles.css'
/** BadgeChip renders one analysis badge, showing confidence when the analyzer supplied it. */
function BadgeChip({label, confidence}: {label: string; confidence: number}) {
const pct = Math.round(confidence * 100)
return (
<span className="badge" title={`confidence ${pct}%`}>
{label}
</span>
)
}
/** PictureCard shows one picture with its badges and how it was captured. */
function PictureCard({image, onOpenPlace}: {image: Image; onOpenPlace: (id: string) => void}) {
const linked = image.linkedImages ?? []
const badges = image.badges ?? []
return (
<figure className="picture">
<img
src={resolveMedia(image.url)}
alt={image.caption || image.cameraModel || 'Uploaded picture'}
loading="lazy"
/>
<figcaption>
{image.caption && <p className="picture-caption">{image.caption}</p>}
{badges.length > 0 && (
<div className="badges">
{badges.map((b) => (
<BadgeChip key={`${b.source}:${b.label}`} label={b.display || b.label} confidence={b.confidence} />
))}
</div>
)}
<dl className="picture-meta">
{image.coordinate && (
<>
<dt>Location</dt>
<dd>
{image.coordinate.latitude.toFixed(5)}, {image.coordinate.longitude.toFixed(5)}
</dd>
</>
)}
{image.takenAt && (
<>
<dt>Taken</dt>
<dd>{new Date(image.takenAt).toLocaleString()}</dd>
</>
)}
{image.cameraModel && (
<>
<dt>Camera</dt>
<dd>
{[image.cameraMake, image.cameraModel].filter(Boolean).join(' ')}
</dd>
</>
)}
{image.analysisStatus === 'failed' && (
<>
<dt>Analysis</dt>
<dd className="error-text">{image.analysisError || 'failed'}</dd>
</>
)}
</dl>
{linked.length > 0 && (
<div className="linked">
<h4>Same place</h4>
<ul>
{linked.map((other) => (
<li key={other.id}>
<button type="button" className="link-button" onClick={() => onOpenPlace(other.placeId)}>
{other.caption || 'Untitled picture'}
</button>
</li>
))}
</ul>
</div>
)}
</figcaption>
</figure>
)
}
/** CreatePlaceForm collects a place and optionally a first picture for it. */
function CreatePlaceForm({onCreated}: {onCreated: (place: Place) => void}) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [latitude, setLatitude] = useState('')
const [longitude, setLongitude] = useState('')
const [file, setFile] = useState<File | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const submit = async (event: React.FormEvent) => {
event.preventDefault()
setBusy(true)
setError(null)
try {
const place = await createPlace({
title,
description,
// An empty field means "no coordinate", which is distinct from 0,0, so
// the optional fields are only sent when both halves are filled in.
...(latitude && longitude
? {latitude: Number(latitude), longitude: Number(longitude)}
: {}),
})
if (file) {
// The place exists either way, so a failed picture upload is reported
// without discarding the place.
try {
await uploadImage(place.id, file, description)
} catch (err) {
setError(`The place was created but the picture was rejected: ${messageOf(err)}`)
onCreated(place)
return
}
}
setTitle('')
setDescription('')
setLatitude('')
setLongitude('')
setFile(null)
onCreated(place)
} catch (err) {
setError(messageOf(err))
} finally {
setBusy(false)
}
}
return (
<form className="create-form" onSubmit={submit}>
<h2>Add a place</h2>
{error && <p className="error-text">{error}</p>}
<label>
Title
<input value={title} onChange={(e) => setTitle(e.target.value)} required maxLength={200} />
</label>
<label>
Description
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
</label>
<div className="row">
<label>
Latitude
<input
type="number"
step="any"
min={-90}
max={90}
value={latitude}
onChange={(e) => setLatitude(e.target.value)}
placeholder="optional"
/>
</label>
<label>
Longitude
<input
type="number"
step="any"
min={-180}
max={180}
value={longitude}
onChange={(e) => setLongitude(e.target.value)}
placeholder="optional"
/>
</label>
</div>
<label>
First picture
<input type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
</label>
<button type="submit" disabled={busy || !title.trim()}>
{busy ? 'Saving…' : 'Create place'}
</button>
</form>
)
}
/**
* TokenBar collects the go-loose token the API requires.
*
* There is no login flow here: identity is owned by the SSO service, and this
* app only has to hand its token to the API. The token is kept in localStorage
* so a reload does not ask for it again.
*/
function TokenBar() {
const [token, setTokenValue] = useState(getToken())
const [show, setShow] = useState(false)
if (!show) {
return (
<div className="row token-bar">
<span className="muted">
{token ? `Token loaded${token === 'valid_token' ? ' (dev)' : ''}` : 'No token set'}
</span>
<button type="button" onClick={() => setShow(true)}>
{token ? 'Change token' : 'Set token'}
</button>
</div>
)
}
return (
<form
className="token-bar"
onSubmit={(e) => {
e.preventDefault()
setToken(token.trim())
// Reloading is the simplest way to re-run the initial load with the new
// identity, and it avoids tracking every dependent piece of state.
window.location.reload()
}}
>
<label>
go-loose token
<input
type="password"
autoComplete="off"
value={token}
onChange={(e) => setTokenValue(e.target.value)}
placeholder="eyJhbGciOiJIUzI1NiIs…"
/>
</label>
<button type="submit">Save</button>
<button type="button" onClick={() => setShow(false)}>
Cancel
</button>
</form>
)
}
/** messageOf renders any thrown value as a sentence for the user. */
function messageOf(err: unknown): string {
if (err instanceof ApiError) {
if (err.status === 401) return 'Unauthorized. Set a valid go-loose token.'
return err.message
}
return err instanceof Error ? err.message : String(err)
}
export default function App() {
const [places, setPlaces] = useState<Place[]>([])
const [selected, setSelected] = useState<Place | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showCreate, setShowCreate] = useState(false)
const refresh = useCallback(async () => {
setLoading(true)
setError(null)
try {
setPlaces(await listPlaces())
} catch (err) {
setError(messageOf(err))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void refresh()
}, [refresh])
const open = useCallback(async (id: string) => {
try {
// The list endpoint returns places without their pictures, so a place is
// re-read here to pick up images, comments and related places.
setSelected(await getPlace(id))
} catch (err) {
setError(messageOf(err))
}
}, [])
const created = useCallback(
(place: Place) => {
setShowCreate(false)
setPlaces((prev) => [place, ...prev])
void open(place.id)
},
[open],
)
return (
<div className="app">
<header>
<h1>Go Places</h1>
<p className="subtitle">Pictures, tagged by where they were taken</p>
<button type="button" onClick={() => setShowCreate((v) => !v)}>
{showCreate ? 'Cancel' : 'Add a place'}
</button>
</header>
<TokenBar />
{error && <p className="error-text">{error}</p>}
{showCreate && <CreatePlaceForm onCreated={created} />}
{selected ? (
<section>
<button type="button" className="link-button" onClick={() => setSelected(null)}>
← All places
</button>
<h2>{selected.title}</h2>
{selected.description && <p>{selected.description}</p>}
{selected.coordinate && (
<p className="muted">
{selected.coordinate.latitude.toFixed(5)}, {selected.coordinate.longitude.toFixed(5)} · links
within {selected.linkRadiusMeters} m
</p>
)}
{selected.relatedPlaces.length > 0 && (
<div className="related-places">
<h3>Possible duplicates</h3>
<ul>
{selected.relatedPlaces.map((r) => (
<li key={r.place.id}>
<button type="button" className="link-button" onClick={() => void open(r.place.id)}>
{r.place.title}
</button>{' '}
<span className="muted">{Math.round(r.distanceMeters)} m away</span>
</li>
))}
</ul>
</div>
)}
<h3>Pictures</h3>
{selected.images.length === 0 ? (
<p className="muted">No pictures yet.</p>
) : (
<div className="pictures">
{selected.images.map((img) => (
<PictureCard key={img.id} image={img} onOpenPlace={(id) => void open(id)} />
))}
</div>
)}
{selected.comments.length > 0 && (
<>
<h3>Comments</h3>
<ul className="comments">
{selected.comments.map((c) => (
<li key={c.id}>
<strong>{c.creator.username || c.creator.id}</strong> {c.content}
</li>
))}
</ul>
</>
)}
</section>
) : (
<section>
<h2>Places</h2>
{loading ? (
<p className="muted">Loading…</p>
) : places.length === 0 ? (
<p className="muted">No places yet. Create the first one.</p>
) : (
<ul className="place-list">
{places.map((p) => (
<li key={p.id}>
<button type="button" className="link-button" onClick={() => void open(p.id)}>
{p.title}
</button>
{p.description && <p className="muted">{p.description}</p>}
<p className="muted">
by {p.creator.username || p.creator.id} · {new Date(p.createdAt).toLocaleDateString()}
</p>
</li>
))}
</ul>
)}
</section>
)}
</div>
)
}

174
web/src/api.ts Normal file
View file

@ -0,0 +1,174 @@
/**
* Client for the go-places API.
*
* Two routes matter to the UI: the REST endpoints behind ENABLE_REST, which
* cover listing places, creating a place and listing its pictures, and the
* multipart upload endpoint. Both are behind the auth middleware, so every
* request carries a bearer token.
*/
import type {CreatePlaceInput, Image, Place} from './types'
/** Stored per browser profile; devToken stands in for a real go-loose token. */
const TOKEN_KEY = 'go-places.token'
const DEV_TOKEN = 'valid_token'
/**
* A build-time token, for deployments that bake credentials into the bundle.
* Empty by default, so nothing ships unless it is asked for.
*/
const BUILT_IN_TOKEN = import.meta.env.VITE_API_TOKEN ?? ''
/**
* getToken returns the bearer token to send, or an empty string when none is
* configured.
*
* The dev token is only substituted in a development build. Falling back to it
* in production would send a well-known credential to a real deployment, and
* would hide the fact that no go-loose token had been configured.
*/
export function getToken(): string {
return localStorage.getItem(TOKEN_KEY) || BUILT_IN_TOKEN || (import.meta.env.DEV ? DEV_TOKEN : '')
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
}
/**
* API_BASE is the origin every request is sent to. Empty by default, which keeps
* the paths relative and therefore same-origin: that is what the Vite dev proxy
* and a co-hosted deployment both want. A cross-origin deployment sets
* VITE_API_BASE at build time.
*/
const API_BASE = (import.meta.env.VITE_API_BASE ?? '').replace(/\/$/, '')
/** apiPath prefixes a route with the configured API origin. */
function apiPath(path: string): string {
return `${API_BASE}${path}`
}
/**
* resolveMedia turns the `url` the API returns into something renderable.
*
* The server sends a root-relative path such as /media/<key>, which only
* resolves when the API is same-origin. Media is served unauthenticated, so an
* <img> can load it without a header.
*/
export function resolveMedia(url: string): string {
return url.startsWith('/') ? apiPath(url) : url
}
/** ApiError carries the status so the UI can distinguish auth from other failures. */
export class ApiError extends Error {
readonly status: number
constructor(status: number, message: string) {
super(message)
this.name = 'ApiError'
this.status = status
}
}
/** readError pulls the server's message out of a JSON error body, falling back to text. */
async function readError(res: Response): Promise<string> {
const body = await res.text()
if (!body) return `request failed with status ${res.status}`
try {
const parsed = JSON.parse(body)
const message = parsed.error ?? parsed.message
if (typeof message === 'string') return message
} catch {
// Not JSON; fall through to the raw body.
}
return body
}
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = getToken()
if (!token) {
// Sent without a token the server answers 401 anyway, but this says why.
throw new ApiError(401, 'no API token is configured')
}
const headers = new Headers(init.headers)
headers.set('Authorization', `Bearer ${token}`)
const res = await fetch(apiPath(path), {...init, headers})
if (!res.ok) {
throw new ApiError(res.status, await readError(res))
}
if (res.status === 204) {
return undefined as T
}
return (await res.json()) as T
}
// The Go handlers serialise an empty result as JSON null rather than [], so an
// empty list is normalised here rather than at every call site.
export async function listPlaces(limit = 50, offset = 0): Promise<Place[]> {
return (await request<Place[] | null>(`/api/places?limit=${limit}&offset=${offset}`)) ?? []
}
export async function getPlace(id: string): Promise<Place> {
const place = await request<Place>(`/api/places/${encodeURIComponent(id)}`)
// The nested collections are omitted when empty, so default them before the
// UI renders. Badges and linked images are the two the UI iterates over.
return {
...place,
images: place.images ?? [],
comments: place.comments ?? [],
relatedPlaces: place.relatedPlaces ?? [],
}
}
export async function listPlaceImages(placeId: string, limit = 100): Promise<Image[]> {
return (
(await request<Image[] | null>(`/api/places/${encodeURIComponent(placeId)}/images?limit=${limit}`)) ?? []
)
}
export function createPlace(input: CreatePlaceInput): Promise<Place> {
return request<Place>('/api/places', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(input),
})
}
/**
* uploadImages posts one picture to a place.
*
* The file's own type is sent explicitly rather than letting the browser guess,
* because the service validates the declared type. An empty result array means
* the picture was stored but the analysis produced no badges.
*/
export async function uploadImage(
placeId: string,
file: File,
caption = '',
): Promise<Image> {
const body = new FormData()
body.append('image', file, file.name)
if (caption) body.append('caption', caption)
const headers = new Headers()
const token = getToken()
if (!token) {
throw new ApiError(401, 'no API token is configured')
}
headers.set('Authorization', `Bearer ${token}`)
// Content-Type is deliberately unset so the browser adds the multipart
// boundary; setting it by hand produces an unparsable body.
const res = await fetch(apiPath(`/places/${encodeURIComponent(placeId)}/images`), {
method: 'POST',
headers,
body,
})
if (!res.ok) {
throw new ApiError(res.status, await readError(res))
}
const payload = (await res.json()) as {image: Image}
return payload.image
}

9
web/src/main.tsx Normal file
View file

@ -0,0 +1,9 @@
import React from 'react';
import {createRoot} from "react-dom/client";
import App from "./App";
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App/>
</React.StrictMode>
)

285
web/src/styles.css Normal file
View file

@ -0,0 +1,285 @@
:root {
color-scheme: light dark;
--bg: #0f1117;
--card: #181b24;
--text: #e7e9ee;
--muted: #949bab;
--accent: #6ea8fe;
--border: #272b36;
--error: #f28b82;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f5f6f8;
--card: #ffffff;
--text: #1c1f26;
--muted: #5c6373;
--accent: #2563eb;
--border: #dfe3ea;
--error: #b3261e;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
line-height: 1.5;
}
.app {
max-width: 68rem;
margin: 0 auto;
padding: 2rem 1.5rem 4rem;
}
header {
border-bottom: 1px solid var(--border);
padding-bottom: 1rem;
margin-bottom: 1.5rem;
}
h1 {
margin: 0;
font-size: 2rem;
}
.subtitle,
.muted {
color: var(--muted);
}
.subtitle {
margin: 0.25rem 0 1rem;
}
button {
font: inherit;
cursor: pointer;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--card);
color: var(--text);
padding: 0.5rem 0.9rem;
}
button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.link-button {
background: none;
border: none;
padding: 0;
color: var(--accent);
text-decoration: underline;
text-align: left;
}
.link-button:hover {
text-decoration: none;
}
.error-text {
color: var(--error);
}
/* ---- create form ---- */
.create-form {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.25rem;
display: grid;
gap: 0.85rem;
margin-bottom: 2rem;
}
.token-bar {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.token-bar label {
display: grid;
gap: 0.3rem;
font-size: 0.9rem;
color: var(--muted);
}
.token-bar input {
font: inherit;
padding: 0.5rem;
min-width: 20rem;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
}
.create-form h2 {
margin: 0;
font-size: 1.2rem;
}
.create-form label {
display: grid;
gap: 0.3rem;
font-size: 0.9rem;
color: var(--muted);
}
.create-form .row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.85rem;
}
.create-form input,
.create-form textarea {
font: inherit;
padding: 0.5rem;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
}
.create-form button[type='submit'] {
justify-self: start;
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
/* ---- lists ---- */
.place-list,
.comments {
list-style: none;
padding: 0;
display: grid;
gap: 1.1rem;
}
.place-list li {
border-bottom: 1px solid var(--border);
padding-bottom: 0.9rem;
}
.place-list p {
margin: 0.2rem 0 0;
font-size: 0.9rem;
}
/* ---- pictures ---- */
.pictures {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
gap: 1.25rem;
}
.picture {
margin: 0;
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.picture img {
width: 100%;
height: 11rem;
object-fit: cover;
display: block;
background: var(--bg);
}
.picture figcaption {
padding: 0.8rem;
}
.picture-caption {
margin: 0 0 0.5rem;
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-bottom: 0.6rem;
}
.badge {
font-size: 0.75rem;
padding: 0.15rem 0.5rem;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--muted);
}
.picture-meta {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.15rem 0.6rem;
margin: 0;
font-size: 0.8rem;
}
.picture-meta dt {
color: var(--muted);
}
.picture-meta dd {
margin: 0;
}
.linked {
margin-top: 0.7rem;
border-top: 1px solid var(--border);
padding-top: 0.5rem;
font-size: 0.85rem;
}
.linked h4 {
margin: 0 0 0.3rem;
font-size: 0.8rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.linked ul,
.related-places ul {
list-style: none;
padding: 0;
margin: 0;
display: grid;
gap: 0.25rem;
}
.related-places {
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.9rem 1.1rem;
margin: 1.25rem 0;
}
.related-places h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}

75
web/src/types.ts Normal file
View file

@ -0,0 +1,75 @@
/** Shapes returned by the go-places API, mirroring internal/domain/models.go. */
export type Coordinate = {
latitude: number
longitude: number
}
export type Badge = {
label: string
display: string
confidence: number
source: string
}
export type User = {
id: string
username: string
email: string
}
export type Image = {
id: string
placeId: string
url: string
caption: string
contentType: string
byteSize: number
width: number
height: number
coordinate: Coordinate | null
takenAt: string | null
cameraMake: string
cameraModel: string
badges: Badge[]
analysisStatus: 'pending' | 'completed' | 'failed'
analysisError: string
linkedImages: Image[]
createdAt: string
}
export type Comment = {
id: string
placeId: string
content: string
creator: User
createdAt: string
}
export type Place = {
id: string
title: string
description: string
coordinate: Coordinate | null
linkRadiusMeters: number
creator: User
images: Image[]
comments: Comment[]
relatedPlaces: RelatedPlace[]
createdAt: string
}
export type RelatedPlace = {
place: Place
distanceMeters: number
sharedImageIds: string[]
}
export type CreatePlaceInput = {
title: string
description: string
latitude?: number
longitude?: number
linkRadiusMeters?: number
caption?: string
}

24
web/tsconfig.json Normal file
View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"types": ["vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"isolatedModules": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "vite.config.ts"]
}

35
web/vite.config.ts Normal file
View file

@ -0,0 +1,35 @@
import {defineConfig, loadEnv} from 'vite'
import react from '@vitejs/plugin-react'
// The dev server proxies the API routes so the browser sees a single origin and
// no CORS configuration is needed during development. /graphql, /media and
// /healthz are listed alongside /api because the service mounts them at the root
// rather than under /api.
export default defineConfig(({mode}) => {
// loadEnv is needed here rather than import.meta.env, because vite.config.ts is
// evaluated before the .env files are exposed to client code. '.' is the env
// dir, which defaults to the project root.
const env = loadEnv(mode, '.', 'VITE_')
const target = env.VITE_PROXY_TARGET || 'http://localhost:8080'
const proxy = {
target,
changeOrigin: true,
secure: false,
}
return {
plugins: [react()],
base: './',
server: {
proxy: {
'/api': proxy,
// GET /graphql only serves the static query console; it cannot execute a
// query, so proxying it is safe.
'/graphql': proxy,
'/media': proxy,
'/healthz': proxy,
},
},
}
})