initial commit
This commit is contained in:
commit
1253955070
75 changed files with 13972 additions and 0 deletions
12
web/index.html
Normal file
12
web/index.html
Normal 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
1227
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
27
web/package.json
Normal file
27
web/package.json
Normal 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
402
web/src/App.tsx
Normal 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
174
web/src/api.ts
Normal 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
9
web/src/main.tsx
Normal 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
285
web/src/styles.css
Normal 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
75
web/src/types.ts
Normal 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
24
web/tsconfig.json
Normal 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
35
web/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue