Arslan1997 commited on
Commit
23cef78
·
1 Parent(s): 0c11338

LETS GOOOOOOOOOOOOOOOOOOOOOOOOO

Browse files
.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
- <NavLink to="/" className="sidebar-brand">
70
- <Logo size={28} />
71
- </NavLink>
 
 
 
 
 
 
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) => positions.get(id)).filter(Boolean) as P[];
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, positions]);
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 = positions.get(id);
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="map-svg"
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 key={g.id} className="nebula" onClick={() => flyToGalaxy(g.id)}>
 
 
 
 
 
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 = positions.get(e.source);
519
- const b = positions.get(e.target);
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 = positions.get(n.id)!;
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
- onClick={() => open(n.id)}
 
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: pointer;
 
846
  transition: opacity 0.2s;
847
  }
848
 
@@ -1304,7 +1305,7 @@ select.input option {
1304
  }
1305
 
1306
  .shell.collapsed {
1307
- grid-template-columns: 72px 1fr;
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: zoom-in;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 {