Spaces:
Runtime error
Runtime error
Commit ·
23cef78
1
Parent(s): 0c11338
LETS GOOOOOOOOOOOOOOOOOOOOOOOOO
Browse files- .env.example +2 -0
- backend/app/config.py +2 -0
- backend/app/services/storage.py +12 -8
- backend/tests/conftest.py +3 -0
- deploy/env.prod.example +2 -0
- frontend/src/components/AppShell.tsx +9 -3
- frontend/src/pages/TopicMap.tsx +115 -11
- frontend/src/styles/pages.css +25 -2
- frontend/src/styles/showcase.css +32 -1
.env.example
CHANGED
|
@@ -54,6 +54,8 @@ R2_SECRET_ACCESS_KEY=minioadmin
|
|
| 54 |
R2_BUCKET=notestack-assets
|
| 55 |
R2_ENDPOINT_URL=
|
| 56 |
R2_PUBLIC_BASE_URL=
|
|
|
|
|
|
|
| 57 |
# How long presigned download links stay valid, in seconds.
|
| 58 |
R2_PRESIGN_TTL_SECONDS=3600
|
| 59 |
# Largest file a browser may upload (bytes). 209715200 = 200 MB.
|
|
|
|
| 54 |
R2_BUCKET=notestack-assets
|
| 55 |
R2_ENDPOINT_URL=
|
| 56 |
R2_PUBLIC_BASE_URL=
|
| 57 |
+
# Folder inside the bucket when it is shared with other apps, e.g. notestack. Empty = bucket root.
|
| 58 |
+
R2_PREFIX=
|
| 59 |
# How long presigned download links stay valid, in seconds.
|
| 60 |
R2_PRESIGN_TTL_SECONDS=3600
|
| 61 |
# Largest file a browser may upload (bytes). 209715200 = 200 MB.
|
backend/app/config.py
CHANGED
|
@@ -58,6 +58,8 @@ class Settings(BaseSettings):
|
|
| 58 |
r2_bucket: str = "notestack-assets"
|
| 59 |
r2_endpoint_url: str = "" # override for MinIO locally; defaults to the R2 account endpoint
|
| 60 |
r2_public_base_url: str = "" # optional custom domain for public objects
|
|
|
|
|
|
|
| 61 |
r2_presign_ttl_seconds: int = 3600
|
| 62 |
max_upload_bytes: int = 200 * 1024 * 1024
|
| 63 |
|
|
|
|
| 58 |
r2_bucket: str = "notestack-assets"
|
| 59 |
r2_endpoint_url: str = "" # override for MinIO locally; defaults to the R2 account endpoint
|
| 60 |
r2_public_base_url: str = "" # optional custom domain for public objects
|
| 61 |
+
# Folder inside the bucket, so Notestack can share a bucket with other apps. Empty = bucket root.
|
| 62 |
+
r2_prefix: str = ""
|
| 63 |
r2_presign_ttl_seconds: int = 3600
|
| 64 |
max_upload_bytes: int = 200 * 1024 * 1024
|
| 65 |
|
backend/app/services/storage.py
CHANGED
|
@@ -59,6 +59,10 @@ def workspace_owns_key(workspace_id: uuid.UUID, key: str) -> bool:
|
|
| 59 |
class Storage:
|
| 60 |
def __init__(self, bucket: str | None = None):
|
| 61 |
self.bucket = bucket or settings.r2_bucket
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
@cached_property
|
| 64 |
def client(self):
|
|
@@ -80,18 +84,18 @@ class Storage:
|
|
| 80 |
|
| 81 |
def put_bytes(self, key: str, data: bytes, content_type: str | None = None) -> str:
|
| 82 |
content_type = content_type or mimetypes.guess_type(key)[0] or "application/octet-stream"
|
| 83 |
-
self.client.put_object(Bucket=self.bucket, Key=key, Body=data, ContentType=content_type)
|
| 84 |
return key
|
| 85 |
|
| 86 |
def put_text(self, key: str, text: str, content_type: str = "text/plain; charset=utf-8") -> str:
|
| 87 |
return self.put_bytes(key, text.encode("utf-8"), content_type)
|
| 88 |
|
| 89 |
def get_bytes(self, key: str) -> bytes:
|
| 90 |
-
return self.client.get_object(Bucket=self.bucket, Key=key)["Body"].read()
|
| 91 |
|
| 92 |
def head(self, key: str) -> dict | None:
|
| 93 |
try:
|
| 94 |
-
return self.client.head_object(Bucket=self.bucket, Key=key)
|
| 95 |
except ClientError as exc:
|
| 96 |
if exc.response.get("Error", {}).get("Code") in {"404", "NoSuchKey", "NotFound"}:
|
| 97 |
return None
|
|
@@ -101,12 +105,12 @@ class Storage:
|
|
| 101 |
return self.head(key) is not None
|
| 102 |
|
| 103 |
def delete(self, key: str) -> None:
|
| 104 |
-
self.client.delete_object(Bucket=self.bucket, Key=key)
|
| 105 |
|
| 106 |
def delete_prefix(self, prefix: str) -> int:
|
| 107 |
deleted = 0
|
| 108 |
paginator = self.client.get_paginator("list_objects_v2")
|
| 109 |
-
for page in paginator.paginate(Bucket=self.bucket, Prefix=prefix):
|
| 110 |
objects = [{"Key": o["Key"]} for o in page.get("Contents", [])]
|
| 111 |
if objects:
|
| 112 |
self.client.delete_objects(Bucket=self.bucket, Delete={"Objects": objects})
|
|
@@ -114,7 +118,7 @@ class Storage:
|
|
| 114 |
return deleted
|
| 115 |
|
| 116 |
def presign_get(self, key: str, ttl: int | None = None, download_name: str | None = None) -> str:
|
| 117 |
-
params = {"Bucket": self.bucket, "Key": key}
|
| 118 |
if download_name:
|
| 119 |
params["ResponseContentDisposition"] = f'attachment; filename="{safe_filename(download_name)}"'
|
| 120 |
return self.client.generate_presigned_url(
|
|
@@ -124,14 +128,14 @@ class Storage:
|
|
| 124 |
def presign_put(self, key: str, content_type: str, ttl: int | None = None) -> str:
|
| 125 |
return self.client.generate_presigned_url(
|
| 126 |
"put_object",
|
| 127 |
-
Params={"Bucket": self.bucket, "Key": key, "ContentType": content_type},
|
| 128 |
ExpiresIn=ttl or settings.r2_presign_ttl_seconds,
|
| 129 |
)
|
| 130 |
|
| 131 |
def public_url(self, key: str) -> str | None:
|
| 132 |
if not settings.r2_public_base_url:
|
| 133 |
return None
|
| 134 |
-
return f"{settings.r2_public_base_url.rstrip('/')}/{key}"
|
| 135 |
|
| 136 |
|
| 137 |
def _local_sig(method: str, key: str, exp: int) -> str:
|
|
|
|
| 59 |
class Storage:
|
| 60 |
def __init__(self, bucket: str | None = None):
|
| 61 |
self.bucket = bucket or settings.r2_bucket
|
| 62 |
+
self.prefix = settings.r2_prefix.strip("/") + "/" if settings.r2_prefix.strip("/") else ""
|
| 63 |
+
|
| 64 |
+
def _k(self, key: str) -> str:
|
| 65 |
+
return f"{self.prefix}{key}"
|
| 66 |
|
| 67 |
@cached_property
|
| 68 |
def client(self):
|
|
|
|
| 84 |
|
| 85 |
def put_bytes(self, key: str, data: bytes, content_type: str | None = None) -> str:
|
| 86 |
content_type = content_type or mimetypes.guess_type(key)[0] or "application/octet-stream"
|
| 87 |
+
self.client.put_object(Bucket=self.bucket, Key=self._k(key), Body=data, ContentType=content_type)
|
| 88 |
return key
|
| 89 |
|
| 90 |
def put_text(self, key: str, text: str, content_type: str = "text/plain; charset=utf-8") -> str:
|
| 91 |
return self.put_bytes(key, text.encode("utf-8"), content_type)
|
| 92 |
|
| 93 |
def get_bytes(self, key: str) -> bytes:
|
| 94 |
+
return self.client.get_object(Bucket=self.bucket, Key=self._k(key))["Body"].read()
|
| 95 |
|
| 96 |
def head(self, key: str) -> dict | None:
|
| 97 |
try:
|
| 98 |
+
return self.client.head_object(Bucket=self.bucket, Key=self._k(key))
|
| 99 |
except ClientError as exc:
|
| 100 |
if exc.response.get("Error", {}).get("Code") in {"404", "NoSuchKey", "NotFound"}:
|
| 101 |
return None
|
|
|
|
| 105 |
return self.head(key) is not None
|
| 106 |
|
| 107 |
def delete(self, key: str) -> None:
|
| 108 |
+
self.client.delete_object(Bucket=self.bucket, Key=self._k(key))
|
| 109 |
|
| 110 |
def delete_prefix(self, prefix: str) -> int:
|
| 111 |
deleted = 0
|
| 112 |
paginator = self.client.get_paginator("list_objects_v2")
|
| 113 |
+
for page in paginator.paginate(Bucket=self.bucket, Prefix=self._k(prefix)):
|
| 114 |
objects = [{"Key": o["Key"]} for o in page.get("Contents", [])]
|
| 115 |
if objects:
|
| 116 |
self.client.delete_objects(Bucket=self.bucket, Delete={"Objects": objects})
|
|
|
|
| 118 |
return deleted
|
| 119 |
|
| 120 |
def presign_get(self, key: str, ttl: int | None = None, download_name: str | None = None) -> str:
|
| 121 |
+
params = {"Bucket": self.bucket, "Key": self._k(key)}
|
| 122 |
if download_name:
|
| 123 |
params["ResponseContentDisposition"] = f'attachment; filename="{safe_filename(download_name)}"'
|
| 124 |
return self.client.generate_presigned_url(
|
|
|
|
| 128 |
def presign_put(self, key: str, content_type: str, ttl: int | None = None) -> str:
|
| 129 |
return self.client.generate_presigned_url(
|
| 130 |
"put_object",
|
| 131 |
+
Params={"Bucket": self.bucket, "Key": self._k(key), "ContentType": content_type},
|
| 132 |
ExpiresIn=ttl or settings.r2_presign_ttl_seconds,
|
| 133 |
)
|
| 134 |
|
| 135 |
def public_url(self, key: str) -> str | None:
|
| 136 |
if not settings.r2_public_base_url:
|
| 137 |
return None
|
| 138 |
+
return f"{settings.r2_public_base_url.rstrip('/')}/{self._k(key)}"
|
| 139 |
|
| 140 |
|
| 141 |
def _local_sig(method: str, key: str, exp: int) -> str:
|
backend/tests/conftest.py
CHANGED
|
@@ -8,6 +8,9 @@ os.environ["CORPUS_CACHE_DIR"] = tempfile.mkdtemp(prefix="notestack-corpus-")
|
|
| 8 |
os.environ["RUN_WORKER_IN_API"] = "false"
|
| 9 |
os.environ["LLM_API_KEY"] = ""
|
| 10 |
os.environ["ELEVENLABS_API_KEY"] = ""
|
|
|
|
|
|
|
|
|
|
| 11 |
os.environ.setdefault("EMAIL_PROVIDER", "console")
|
| 12 |
os.environ.setdefault("JWT_SECRET", "test-secret-that-is-at-least-32-bytes-long")
|
| 13 |
os.environ.setdefault("BILLING_ENABLED", "false")
|
|
|
|
| 8 |
os.environ["RUN_WORKER_IN_API"] = "false"
|
| 9 |
os.environ["LLM_API_KEY"] = ""
|
| 10 |
os.environ["ELEVENLABS_API_KEY"] = ""
|
| 11 |
+
os.environ["FIRECRAWL_API_KEY"] = ""
|
| 12 |
+
os.environ["R2_PREFIX"] = ""
|
| 13 |
+
os.environ["TYPESAFE_API_KEY"] = ""
|
| 14 |
os.environ.setdefault("EMAIL_PROVIDER", "console")
|
| 15 |
os.environ.setdefault("JWT_SECRET", "test-secret-that-is-at-least-32-bytes-long")
|
| 16 |
os.environ.setdefault("BILLING_ENABLED", "false")
|
deploy/env.prod.example
CHANGED
|
@@ -46,6 +46,8 @@ R2_ACCOUNT_ID=
|
|
| 46 |
R2_ACCESS_KEY_ID=
|
| 47 |
R2_SECRET_ACCESS_KEY=
|
| 48 |
R2_BUCKET=notestack-assets
|
|
|
|
|
|
|
| 49 |
|
| 50 |
# ---- AI
|
| 51 |
LLM_API_KEY=<z.ai key>
|
|
|
|
| 46 |
R2_ACCESS_KEY_ID=
|
| 47 |
R2_SECRET_ACCESS_KEY=
|
| 48 |
R2_BUCKET=notestack-assets
|
| 49 |
+
# Sharing a bucket with other apps? Keep Notestack in its own folder:
|
| 50 |
+
R2_PREFIX=
|
| 51 |
|
| 52 |
# ---- AI
|
| 53 |
LLM_API_KEY=<z.ai key>
|
frontend/src/components/AppShell.tsx
CHANGED
|
@@ -66,9 +66,15 @@ export default function AppShell() {
|
|
| 66 |
<SkyCanvas intensity={0.35} />
|
| 67 |
<aside className="sidebar">
|
| 68 |
<div className="sidebar-top">
|
| 69 |
-
|
| 70 |
-
<
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
<button
|
| 73 |
type="button"
|
| 74 |
className="icon-btn sidebar-toggle"
|
|
|
|
| 66 |
<SkyCanvas intensity={0.35} />
|
| 67 |
<aside className="sidebar">
|
| 68 |
<div className="sidebar-top">
|
| 69 |
+
{collapsed ? (
|
| 70 |
+
<button type="button" className="sidebar-brand sidebar-brand-btn" onClick={toggle} aria-label="Expand sidebar" title="Expand sidebar">
|
| 71 |
+
<Logo size={28} />
|
| 72 |
+
</button>
|
| 73 |
+
) : (
|
| 74 |
+
<NavLink to="/" className="sidebar-brand">
|
| 75 |
+
<Logo size={28} />
|
| 76 |
+
</NavLink>
|
| 77 |
+
)}
|
| 78 |
<button
|
| 79 |
type="button"
|
| 80 |
className="icon-btn sidebar-toggle"
|
frontend/src/pages/TopicMap.tsx
CHANGED
|
@@ -26,6 +26,26 @@ type View = { x: number; y: number; w: number; h: number };
|
|
| 26 |
type ColorMode = "recency" | "momentum";
|
| 27 |
|
| 28 |
const HOME: View = { x: 0, y: 0, w: W, h: H };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
const MIN_W = W / 8;
|
| 30 |
const MAX_W = W * 1.6;
|
| 31 |
|
|
@@ -204,6 +224,7 @@ function useZoomPan(svgRef: RefObject<SVGSVGElement>) {
|
|
| 204 |
|
| 205 |
return {
|
| 206 |
view,
|
|
|
|
| 207 |
zoomAt,
|
| 208 |
pan,
|
| 209 |
reset,
|
|
@@ -284,7 +305,10 @@ export default function TopicMap() {
|
|
| 284 |
const [error, setError] = useState<string | null>(null);
|
| 285 |
const navigate = useNavigate();
|
| 286 |
const svgRef = useRef<SVGSVGElement>(null);
|
| 287 |
-
const { view, zoomAt, pan, reset, focusOn, handlers } = useZoomPan(svgRef);
|
|
|
|
|
|
|
|
|
|
| 288 |
|
| 289 |
const load = () => topicsApi.map().then(setData, () => setError("Could not load the topic map."));
|
| 290 |
useEffect(() => {
|
|
@@ -295,6 +319,17 @@ export default function TopicMap() {
|
|
| 295 |
});
|
| 296 |
|
| 297 |
const positions = useMemo(() => (data?.nodes.length ? layout(data) : new Map<string, P>()), [data]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
const byId = useMemo(() => new Map((data?.nodes ?? []).map((n) => [n.id, n])), [data]);
|
| 299 |
const neighbors = useMemo(() => {
|
| 300 |
const m = new Map<string, Set<string>>();
|
|
@@ -312,14 +347,14 @@ export default function TopicMap() {
|
|
| 312 |
return data.galaxies
|
| 313 |
.filter((g) => g.size > 1)
|
| 314 |
.map((g) => {
|
| 315 |
-
const pts = g.topic_ids.map((id) =>
|
| 316 |
const cx = pts.reduce((s, p) => s + p.x, 0) / Math.max(pts.length, 1);
|
| 317 |
const cy = pts.reduce((s, p) => s + p.y, 0) / Math.max(pts.length, 1);
|
| 318 |
const rx = Math.max(90, ...pts.map((p) => Math.abs(p.x - cx) + p.r)) + 50;
|
| 319 |
const ry = Math.max(80, ...pts.map((p) => Math.abs(p.y - cy) + p.r)) + 45;
|
| 320 |
return { ...g, cx, cy, rx, ry };
|
| 321 |
});
|
| 322 |
-
}, [data,
|
| 323 |
|
| 324 |
const brightest = useMemo(
|
| 325 |
() => new Set((data?.nodes ?? []).slice().sort((a, b) => b.post_count - a.post_count).slice(0, 3).map((n) => n.id)),
|
|
@@ -342,6 +377,7 @@ export default function TopicMap() {
|
|
| 342 |
const rebuild = async (full: boolean) => {
|
| 343 |
setError(null);
|
| 344 |
try {
|
|
|
|
| 345 |
setJob(await topicsApi.rebuild(full));
|
| 346 |
} catch (e) {
|
| 347 |
setError(errorMessage(e));
|
|
@@ -351,12 +387,60 @@ export default function TopicMap() {
|
|
| 351 |
const open = async (id: string, center = false) => {
|
| 352 |
playClick();
|
| 353 |
if (center) {
|
| 354 |
-
const p =
|
| 355 |
if (p) focusOn(p.x, p.y);
|
| 356 |
}
|
| 357 |
setSelected(await topicsApi.get(id));
|
| 358 |
};
|
| 359 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
const flyToGalaxy = (gid: number) => {
|
| 361 |
const n = nebulae.find((g) => g.id === gid);
|
| 362 |
if (n) focusOn(n.cx, n.cy, Math.max(n.rx * 2.6, (n.ry * 2.6 * W) / H));
|
|
@@ -417,6 +501,11 @@ export default function TopicMap() {
|
|
| 417 |
<button className="icon-btn mono" onClick={reset} aria-label="Reset view" title="Reset view">
|
| 418 |
1:1
|
| 419 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
</div>
|
| 421 |
|
| 422 |
<div className="map-hud mono" aria-live="polite">
|
|
@@ -430,7 +519,7 @@ export default function TopicMap() {
|
|
| 430 |
</>
|
| 431 |
) : (
|
| 432 |
<span className="muted">
|
| 433 |
-
{data.nodes.length} stars · {galaxyCount} galaxies · scroll to zoom, drag to pan
|
| 434 |
</span>
|
| 435 |
)}
|
| 436 |
</div>
|
|
@@ -464,7 +553,7 @@ export default function TopicMap() {
|
|
| 464 |
role="img"
|
| 465 |
aria-label="Topic constellation. Plus and minus zoom, arrow keys pan, 0 resets."
|
| 466 |
tabIndex={0}
|
| 467 |
-
className=
|
| 468 |
style={{ aspectRatio: `${W} / ${H}` }}
|
| 469 |
{...handlers}
|
| 470 |
onKeyDown={(e) => {
|
|
@@ -491,6 +580,13 @@ export default function TopicMap() {
|
|
| 491 |
<stop offset="60%" stopColor="#217cff" stopOpacity="0.06" />
|
| 492 |
<stop offset="100%" stopColor="#217cff" stopOpacity="0" />
|
| 493 |
</radialGradient>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 494 |
<linearGradient id="comet" x1="0" y1="0" x2="1" y2="0">
|
| 495 |
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.85" />
|
| 496 |
<stop offset="100%" stopColor="#217cff" stopOpacity="0" />
|
|
@@ -500,8 +596,15 @@ export default function TopicMap() {
|
|
| 500 |
<DeepField />
|
| 501 |
|
| 502 |
{nebulae.map((g) => (
|
| 503 |
-
<g
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 504 |
<ellipse cx={g.cx} cy={g.cy} rx={g.rx} ry={g.ry} fill="url(#nebula)" />
|
|
|
|
|
|
|
| 505 |
<text
|
| 506 |
x={g.cx}
|
| 507 |
y={g.cy - g.ry + 18 * scale}
|
|
@@ -515,8 +618,8 @@ export default function TopicMap() {
|
|
| 515 |
))}
|
| 516 |
|
| 517 |
{data.edges.map((e) => {
|
| 518 |
-
const a =
|
| 519 |
-
const b =
|
| 520 |
if (!a || !b) return null;
|
| 521 |
const lit = focus && (e.source === focus || e.target === focus);
|
| 522 |
const strength = e.weight / maxEdge;
|
|
@@ -537,7 +640,7 @@ export default function TopicMap() {
|
|
| 537 |
})}
|
| 538 |
|
| 539 |
{data.nodes.map((n) => {
|
| 540 |
-
const p =
|
| 541 |
const dim = focus && focus !== n.id && !neighbors.get(focus)?.has(n.id);
|
| 542 |
const active = selected?.id === n.id;
|
| 543 |
const color = starColor(n);
|
|
@@ -552,7 +655,8 @@ export default function TopicMap() {
|
|
| 552 |
opacity={dim ? 0.2 : 1}
|
| 553 |
onMouseEnter={() => setHover(n.id)}
|
| 554 |
onMouseLeave={() => setHover(null)}
|
| 555 |
-
|
|
|
|
| 556 |
onDoubleClick={() => open(n.id, true)}
|
| 557 |
tabIndex={0}
|
| 558 |
role="button"
|
|
|
|
| 26 |
type ColorMode = "recency" | "momentum";
|
| 27 |
|
| 28 |
const HOME: View = { x: 0, y: 0, w: W, h: H };
|
| 29 |
+
const PINS_KEY = "notestack.topicmap.pins";
|
| 30 |
+
|
| 31 |
+
type Pins = Record<string, { x: number; y: number }>;
|
| 32 |
+
|
| 33 |
+
function readPins(ids: Set<string>): Pins {
|
| 34 |
+
try {
|
| 35 |
+
const all = JSON.parse(localStorage.getItem(PINS_KEY) || "{}") as Pins;
|
| 36 |
+
return Object.fromEntries(Object.entries(all).filter(([id]) => ids.has(id)));
|
| 37 |
+
} catch {
|
| 38 |
+
return {};
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function writePins(pins: Pins) {
|
| 43 |
+
try {
|
| 44 |
+
localStorage.setItem(PINS_KEY, JSON.stringify(pins));
|
| 45 |
+
} catch {
|
| 46 |
+
// Storage blocked: positions just last for this visit.
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
const MIN_W = W / 8;
|
| 50 |
const MAX_W = W * 1.6;
|
| 51 |
|
|
|
|
| 224 |
|
| 225 |
return {
|
| 226 |
view,
|
| 227 |
+
toSvg,
|
| 228 |
zoomAt,
|
| 229 |
pan,
|
| 230 |
reset,
|
|
|
|
| 305 |
const [error, setError] = useState<string | null>(null);
|
| 306 |
const navigate = useNavigate();
|
| 307 |
const svgRef = useRef<SVGSVGElement>(null);
|
| 308 |
+
const { view, toSvg, zoomAt, pan, reset, focusOn, handlers } = useZoomPan(svgRef);
|
| 309 |
+
const [pins, setPins] = useState<Pins>({});
|
| 310 |
+
const [dragging, setDragging] = useState(false);
|
| 311 |
+
const justDragged = useRef(false);
|
| 312 |
|
| 313 |
const load = () => topicsApi.map().then(setData, () => setError("Could not load the topic map."));
|
| 314 |
useEffect(() => {
|
|
|
|
| 319 |
});
|
| 320 |
|
| 321 |
const positions = useMemo(() => (data?.nodes.length ? layout(data) : new Map<string, P>()), [data]);
|
| 322 |
+
const placed = useMemo(() => {
|
| 323 |
+
const m = new Map<string, P>();
|
| 324 |
+
positions.forEach((p, id) => m.set(id, pins[id] ? { ...p, ...pins[id] } : p));
|
| 325 |
+
return m;
|
| 326 |
+
}, [positions, pins]);
|
| 327 |
+
useEffect(() => {
|
| 328 |
+
if (data) setPins(readPins(new Set(data.nodes.map((n) => n.id))));
|
| 329 |
+
}, [data]);
|
| 330 |
+
useEffect(() => {
|
| 331 |
+
if (!dragging && data) writePins(pins);
|
| 332 |
+
}, [pins, dragging, data]);
|
| 333 |
const byId = useMemo(() => new Map((data?.nodes ?? []).map((n) => [n.id, n])), [data]);
|
| 334 |
const neighbors = useMemo(() => {
|
| 335 |
const m = new Map<string, Set<string>>();
|
|
|
|
| 347 |
return data.galaxies
|
| 348 |
.filter((g) => g.size > 1)
|
| 349 |
.map((g) => {
|
| 350 |
+
const pts = g.topic_ids.map((id) => placed.get(id)).filter(Boolean) as P[];
|
| 351 |
const cx = pts.reduce((s, p) => s + p.x, 0) / Math.max(pts.length, 1);
|
| 352 |
const cy = pts.reduce((s, p) => s + p.y, 0) / Math.max(pts.length, 1);
|
| 353 |
const rx = Math.max(90, ...pts.map((p) => Math.abs(p.x - cx) + p.r)) + 50;
|
| 354 |
const ry = Math.max(80, ...pts.map((p) => Math.abs(p.y - cy) + p.r)) + 45;
|
| 355 |
return { ...g, cx, cy, rx, ry };
|
| 356 |
});
|
| 357 |
+
}, [data, placed]);
|
| 358 |
|
| 359 |
const brightest = useMemo(
|
| 360 |
() => new Set((data?.nodes ?? []).slice().sort((a, b) => b.post_count - a.post_count).slice(0, 3).map((n) => n.id)),
|
|
|
|
| 377 |
const rebuild = async (full: boolean) => {
|
| 378 |
setError(null);
|
| 379 |
try {
|
| 380 |
+
if (full) setPins({});
|
| 381 |
setJob(await topicsApi.rebuild(full));
|
| 382 |
} catch (e) {
|
| 383 |
setError(errorMessage(e));
|
|
|
|
| 387 |
const open = async (id: string, center = false) => {
|
| 388 |
playClick();
|
| 389 |
if (center) {
|
| 390 |
+
const p = placed.get(id);
|
| 391 |
if (p) focusOn(p.x, p.y);
|
| 392 |
}
|
| 393 |
setSelected(await topicsApi.get(id));
|
| 394 |
};
|
| 395 |
|
| 396 |
+
const startDrag = (e: ReactPointerEvent, ids: string[]) => {
|
| 397 |
+
if (e.button !== 0) return;
|
| 398 |
+
e.stopPropagation();
|
| 399 |
+
justDragged.current = false;
|
| 400 |
+
const v = view;
|
| 401 |
+
const origin = toSvg(e.clientX, e.clientY, v);
|
| 402 |
+
const start = Object.fromEntries(ids.map((id) => [id, placed.get(id)!]).filter(([, p]) => p)) as Record<string, P>;
|
| 403 |
+
let moved = false;
|
| 404 |
+
const move = (ev: PointerEvent) => {
|
| 405 |
+
const p = toSvg(ev.clientX, ev.clientY, v);
|
| 406 |
+
const dx = p.x - origin.x;
|
| 407 |
+
const dy = p.y - origin.y;
|
| 408 |
+
if (!moved) {
|
| 409 |
+
if (Math.hypot(dx, dy) < 4 * (v.w / W)) return;
|
| 410 |
+
moved = true;
|
| 411 |
+
setDragging(true);
|
| 412 |
+
}
|
| 413 |
+
setPins((prev) => {
|
| 414 |
+
const next = { ...prev };
|
| 415 |
+
for (const [id, s] of Object.entries(start)) next[id] = { x: s.x + dx, y: s.y + dy };
|
| 416 |
+
return next;
|
| 417 |
+
});
|
| 418 |
+
};
|
| 419 |
+
const up = () => {
|
| 420 |
+
window.removeEventListener("pointermove", move);
|
| 421 |
+
window.removeEventListener("pointerup", up);
|
| 422 |
+
window.removeEventListener("pointercancel", up);
|
| 423 |
+
if (moved) {
|
| 424 |
+
justDragged.current = true;
|
| 425 |
+
setDragging(false);
|
| 426 |
+
}
|
| 427 |
+
};
|
| 428 |
+
window.addEventListener("pointermove", move);
|
| 429 |
+
window.addEventListener("pointerup", up);
|
| 430 |
+
window.addEventListener("pointercancel", up);
|
| 431 |
+
};
|
| 432 |
+
/** A click that ends a drag is not a click. */
|
| 433 |
+
const wasDrag = () => {
|
| 434 |
+
const d = justDragged.current;
|
| 435 |
+
justDragged.current = false;
|
| 436 |
+
return d;
|
| 437 |
+
};
|
| 438 |
+
const resetPins = () => {
|
| 439 |
+
playClick();
|
| 440 |
+
setPins({});
|
| 441 |
+
};
|
| 442 |
+
const hasPins = Object.keys(pins).length > 0;
|
| 443 |
+
|
| 444 |
const flyToGalaxy = (gid: number) => {
|
| 445 |
const n = nebulae.find((g) => g.id === gid);
|
| 446 |
if (n) focusOn(n.cx, n.cy, Math.max(n.rx * 2.6, (n.ry * 2.6 * W) / H));
|
|
|
|
| 501 |
<button className="icon-btn mono" onClick={reset} aria-label="Reset view" title="Reset view">
|
| 502 |
1:1
|
| 503 |
</button>
|
| 504 |
+
{hasPins && (
|
| 505 |
+
<button className="icon-btn mono" onClick={resetPins} aria-label="Reset star positions" title="Reset star positions">
|
| 506 |
+
↺
|
| 507 |
+
</button>
|
| 508 |
+
)}
|
| 509 |
</div>
|
| 510 |
|
| 511 |
<div className="map-hud mono" aria-live="polite">
|
|
|
|
| 519 |
</>
|
| 520 |
) : (
|
| 521 |
<span className="muted">
|
| 522 |
+
{data.nodes.length} stars · {galaxyCount} galaxies · scroll to zoom, drag space to pan, drag stars or galaxies to move them
|
| 523 |
</span>
|
| 524 |
)}
|
| 525 |
</div>
|
|
|
|
| 553 |
role="img"
|
| 554 |
aria-label="Topic constellation. Plus and minus zoom, arrow keys pan, 0 resets."
|
| 555 |
tabIndex={0}
|
| 556 |
+
className={`map-svg${dragging ? " dragging" : ""}`}
|
| 557 |
style={{ aspectRatio: `${W} / ${H}` }}
|
| 558 |
{...handlers}
|
| 559 |
onKeyDown={(e) => {
|
|
|
|
| 580 |
<stop offset="60%" stopColor="#217cff" stopOpacity="0.06" />
|
| 581 |
<stop offset="100%" stopColor="#217cff" stopOpacity="0" />
|
| 582 |
</radialGradient>
|
| 583 |
+
<radialGradient id="galaxy-core" cx="50%" cy="50%" r="50%">
|
| 584 |
+
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.7" />
|
| 585 |
+
<stop offset="12%" stopColor="#ffffff" stopOpacity="0.32" />
|
| 586 |
+
<stop offset="40%" stopColor="#ffffff" stopOpacity="0.08" />
|
| 587 |
+
<stop offset="70%" stopColor="#217cff" stopOpacity="0.05" />
|
| 588 |
+
<stop offset="100%" stopColor="#217cff" stopOpacity="0" />
|
| 589 |
+
</radialGradient>
|
| 590 |
<linearGradient id="comet" x1="0" y1="0" x2="1" y2="0">
|
| 591 |
<stop offset="0%" stopColor="#ffffff" stopOpacity="0.85" />
|
| 592 |
<stop offset="100%" stopColor="#217cff" stopOpacity="0" />
|
|
|
|
| 596 |
<DeepField />
|
| 597 |
|
| 598 |
{nebulae.map((g) => (
|
| 599 |
+
<g
|
| 600 |
+
key={g.id}
|
| 601 |
+
className="nebula"
|
| 602 |
+
onPointerDown={(e) => startDrag(e, g.topic_ids)}
|
| 603 |
+
onClick={() => !wasDrag() && flyToGalaxy(g.id)}
|
| 604 |
+
>
|
| 605 |
<ellipse cx={g.cx} cy={g.cy} rx={g.rx} ry={g.ry} fill="url(#nebula)" />
|
| 606 |
+
<circle cx={g.cx} cy={g.cy} r={Math.min(g.rx, g.ry) * 0.75} fill="url(#galaxy-core)" className="galaxy-core" />
|
| 607 |
+
<circle cx={g.cx} cy={g.cy} r={2.4 * scale} fill="#ffffff" className="galaxy-heart" />
|
| 608 |
<text
|
| 609 |
x={g.cx}
|
| 610 |
y={g.cy - g.ry + 18 * scale}
|
|
|
|
| 618 |
))}
|
| 619 |
|
| 620 |
{data.edges.map((e) => {
|
| 621 |
+
const a = placed.get(e.source);
|
| 622 |
+
const b = placed.get(e.target);
|
| 623 |
if (!a || !b) return null;
|
| 624 |
const lit = focus && (e.source === focus || e.target === focus);
|
| 625 |
const strength = e.weight / maxEdge;
|
|
|
|
| 640 |
})}
|
| 641 |
|
| 642 |
{data.nodes.map((n) => {
|
| 643 |
+
const p = placed.get(n.id)!;
|
| 644 |
const dim = focus && focus !== n.id && !neighbors.get(focus)?.has(n.id);
|
| 645 |
const active = selected?.id === n.id;
|
| 646 |
const color = starColor(n);
|
|
|
|
| 655 |
opacity={dim ? 0.2 : 1}
|
| 656 |
onMouseEnter={() => setHover(n.id)}
|
| 657 |
onMouseLeave={() => setHover(null)}
|
| 658 |
+
onPointerDown={(e) => startDrag(e, [n.id])}
|
| 659 |
+
onClick={() => !wasDrag() && open(n.id)}
|
| 660 |
onDoubleClick={() => open(n.id, true)}
|
| 661 |
tabIndex={0}
|
| 662 |
role="button"
|
frontend/src/styles/pages.css
CHANGED
|
@@ -842,7 +842,8 @@ select.input option {
|
|
| 842 |
}
|
| 843 |
|
| 844 |
.map-node {
|
| 845 |
-
cursor:
|
|
|
|
| 846 |
transition: opacity 0.2s;
|
| 847 |
}
|
| 848 |
|
|
@@ -1304,7 +1305,7 @@ select.input option {
|
|
| 1304 |
}
|
| 1305 |
|
| 1306 |
.shell.collapsed {
|
| 1307 |
-
grid-template-columns:
|
| 1308 |
}
|
| 1309 |
|
| 1310 |
.shell.collapsed .sidebar {
|
|
@@ -1316,6 +1317,28 @@ select.input option {
|
|
| 1316 |
flex-direction: column;
|
| 1317 |
}
|
| 1318 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1319 |
.shell.collapsed .side-link {
|
| 1320 |
justify-content: center;
|
| 1321 |
padding: 10px;
|
|
|
|
| 842 |
}
|
| 843 |
|
| 844 |
.map-node {
|
| 845 |
+
cursor: grab;
|
| 846 |
+
touch-action: none;
|
| 847 |
transition: opacity 0.2s;
|
| 848 |
}
|
| 849 |
|
|
|
|
| 1305 |
}
|
| 1306 |
|
| 1307 |
.shell.collapsed {
|
| 1308 |
+
grid-template-columns: 64px 1fr;
|
| 1309 |
}
|
| 1310 |
|
| 1311 |
.shell.collapsed .sidebar {
|
|
|
|
| 1317 |
flex-direction: column;
|
| 1318 |
}
|
| 1319 |
|
| 1320 |
+
/* Collapsed: only the logo, which expands the sidebar again. */
|
| 1321 |
+
.shell.collapsed .sidebar-toggle,
|
| 1322 |
+
.shell.collapsed .sidebar nav,
|
| 1323 |
+
.shell.collapsed .sidebar-user {
|
| 1324 |
+
display: none;
|
| 1325 |
+
}
|
| 1326 |
+
|
| 1327 |
+
.sidebar-brand-btn {
|
| 1328 |
+
padding: 4px;
|
| 1329 |
+
border: none;
|
| 1330 |
+
border-radius: var(--radius-sm);
|
| 1331 |
+
background: transparent;
|
| 1332 |
+
cursor: pointer;
|
| 1333 |
+
transition: box-shadow 0.2s ease;
|
| 1334 |
+
}
|
| 1335 |
+
|
| 1336 |
+
.sidebar-brand-btn:hover,
|
| 1337 |
+
.sidebar-brand-btn:focus-visible {
|
| 1338 |
+
outline: none;
|
| 1339 |
+
box-shadow: 0 0 0 1px var(--blue-60), 0 0 16px var(--blue-35);
|
| 1340 |
+
}
|
| 1341 |
+
|
| 1342 |
.shell.collapsed .side-link {
|
| 1343 |
justify-content: center;
|
| 1344 |
padding: 10px;
|
frontend/src/styles/showcase.css
CHANGED
|
@@ -603,7 +603,38 @@
|
|
| 603 |
}
|
| 604 |
|
| 605 |
.nebula {
|
| 606 |
-
cursor:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 607 |
}
|
| 608 |
|
| 609 |
.galaxy-label {
|
|
|
|
| 603 |
}
|
| 604 |
|
| 605 |
.nebula {
|
| 606 |
+
cursor: grab;
|
| 607 |
+
}
|
| 608 |
+
|
| 609 |
+
.map-svg.dragging,
|
| 610 |
+
.map-svg.dragging .nebula,
|
| 611 |
+
.map-svg.dragging .map-node {
|
| 612 |
+
cursor: grabbing;
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
.galaxy-core {
|
| 616 |
+
pointer-events: none;
|
| 617 |
+
transform-box: fill-box;
|
| 618 |
+
transform-origin: center;
|
| 619 |
+
animation: core-breathe 6s ease-in-out infinite;
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
.galaxy-heart {
|
| 623 |
+
pointer-events: none;
|
| 624 |
+
filter: drop-shadow(0 0 4px #ffffff) drop-shadow(0 0 10px #217cff);
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
@keyframes core-breathe {
|
| 628 |
+
50% {
|
| 629 |
+
opacity: 0.75;
|
| 630 |
+
transform: scale(0.94);
|
| 631 |
+
}
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
@media (prefers-reduced-motion: reduce) {
|
| 635 |
+
.galaxy-core {
|
| 636 |
+
animation: none;
|
| 637 |
+
}
|
| 638 |
}
|
| 639 |
|
| 640 |
.galaxy-label {
|