@raingor/pi-web-switch 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,15 @@
1
- import { useState, useEffect, useCallback } from "react";
1
+ import { useState, useEffect, useCallback, useMemo } from "react";
2
2
  import { useConfigStore } from "@/store/config-store";
3
3
  import { useTranslation } from "@/lib/i18n";
4
4
  import { useCurrency } from "@/lib/currency";
5
5
  import { formatCost, formatNumber, cn, USD_TO_CNY } from "@/lib/utils";
6
6
  import {
7
7
  Activity, DollarSign, BarChart3, ArrowUp, ArrowDown, Database, DollarSignIcon, RefreshCw, Download,
8
+ Gauge, Layers3, Clock3, Zap, CircleDollarSign, Cpu, PieChart as PieChartIcon,
8
9
  } from "lucide-react";
9
10
  import {
10
11
  AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
11
- CartesianGrid, Legend,
12
+ CartesianGrid, Legend, BarChart, Bar, PieChart, Pie, Cell,
12
13
  } from "recharts";
13
14
 
14
15
  // ─── Types ──────────────────────────────────────────────
@@ -70,21 +71,21 @@ interface UsageRangeData {
70
71
  notice?: "no-config" | "api-error";
71
72
  }
72
73
 
73
- type SourceKey = "all" | "pi" | "cindy-pi" | "claude" | "codex" | "opencode" | "gemini" | "grok" | "atomcode" | "copilot";
74
+ type SourceKey = "pi" | "chatgpt";
74
75
  type RangeKey = "today" | "7d" | "30d" | "custom";
75
76
  type TabKey = "log" | "provider" | "model";
76
77
  type SortDir = "asc" | "desc";
77
78
 
78
79
  const RANGE_KEYS: RangeKey[] = ["today", "7d", "30d", "custom"];
79
80
 
80
- const COLORS = ["#3b82f6", "#10b981", "#8b5cf6", "#f59e0b", "#ef4444"];
81
+ const COLORS = ["#00d8ff", "#9ef01a", "#ffb84d", "#9f8cff", "#ff5c7a"];
81
82
 
82
83
  const CHART_LINE_COLORS: Record<string, string> = {
83
- input: "#3b82f6",
84
- output: "#10b981",
85
- cacheRead: "#8b5cf6",
86
- cacheWrite: "#f59e0b",
87
- cost: "#ef4444",
84
+ input: "#00d8ff",
85
+ output: "#9ef01a",
86
+ cacheRead: "#9f8cff",
87
+ cacheWrite: "#ffb84d",
88
+ cost: "#ff5c7a",
88
89
  };
89
90
 
90
91
  const LOG_PAGE_SIZE = 20;
@@ -199,7 +200,7 @@ function StatCard({
199
200
  className?: string;
200
201
  }) {
201
202
  return (
202
- <div className={cn("rounded-xl border p-5", className)} style={{ borderColor: "var(--card-border)", backgroundColor: "var(--card-bg)" }}>
203
+ <div className={cn("tech-panel dashboard-stat-card p-5", className)} style={{ borderColor: "var(--card-border)", backgroundColor: "var(--card-bg)" }}>
203
204
  <div className="flex items-start justify-between mb-3">
204
205
  <p className="text-xs font-medium uppercase tracking-wider" style={{ color: "var(--muted-text)" }}>{title}</p>
205
206
  <div className="rounded-lg p-2" style={{ backgroundColor: "var(--accent-bg)" }}>
@@ -269,7 +270,7 @@ function SortableTh({
269
270
  return (
270
271
  <th
271
272
  className="px-4 py-3 text-right font-medium cursor-pointer select-none"
272
- style={{ color: active ? "#3b82f6" : "var(--muted-text)" }}
273
+ style={{ color: active ? "var(--signal-cyan)" : "var(--muted-text)" }}
273
274
  onClick={() => onSort(sortKey)}
274
275
  >
275
276
  <span className="inline-flex items-center gap-0.5">
@@ -280,6 +281,67 @@ function SortableTh({
280
281
  );
281
282
  }
282
283
 
284
+ // ─── Analytics Components ───────────────────────────────
285
+
286
+ function AnalyticsMetric({
287
+ label,
288
+ value,
289
+ detail,
290
+ icon,
291
+ accent = "var(--signal-cyan)",
292
+ }: {
293
+ label: string;
294
+ value: string;
295
+ detail: string;
296
+ icon: React.ReactNode;
297
+ accent?: string;
298
+ }) {
299
+ return (
300
+ <div className="analytics-metric">
301
+ <div className="analytics-metric-icon" style={{ color: accent, borderColor: `${accent}38`, backgroundColor: `${accent}12` }}>
302
+ {icon}
303
+ </div>
304
+ <div className="min-w-0">
305
+ <p className="analytics-metric-label">{label}</p>
306
+ <p className="analytics-metric-value">{value}</p>
307
+ <p className="analytics-metric-detail">{detail}</p>
308
+ </div>
309
+ </div>
310
+ );
311
+ }
312
+
313
+ function DistributionRow({
314
+ name,
315
+ meta,
316
+ value,
317
+ percentage,
318
+ color,
319
+ valueLabel,
320
+ }: {
321
+ name: string;
322
+ meta: string;
323
+ value: number;
324
+ percentage: number;
325
+ color: string;
326
+ valueLabel: string;
327
+ }) {
328
+ return (
329
+ <div className="distribution-row">
330
+ <div className="distribution-row-head">
331
+ <div className="min-w-0">
332
+ <span className="distribution-name">{name}</span>
333
+ <span className="distribution-meta">{meta}</span>
334
+ </div>
335
+ <span className="distribution-value">{valueLabel}</span>
336
+ </div>
337
+ <div className="distribution-track">
338
+ <span style={{ width: `${Math.max(percentage, percentage > 0 ? 1.5 : 0)}%`, backgroundColor: color }} />
339
+ </div>
340
+ <span className="distribution-percent">{percentage.toFixed(1)}%</span>
341
+ </div>
342
+ );
343
+ }
344
+
283
345
  // ─── Main Component ─────────────────────────────────────
284
346
 
285
347
  export function DashboardPage() {
@@ -307,16 +369,9 @@ export function DashboardPage() {
307
369
 
308
370
  const fetchData = useCallback((force = false) => {
309
371
  if (!initialized || customInvalid) return;
310
- let baseUrl = "/api/pi/usage-range";
311
- if (source === "all") baseUrl = "/api/pi/all-usage-range";
312
- else if (source === "cindy-pi") baseUrl = "/api/pi/cindy-usage-range";
313
- else if (source === "claude") baseUrl = "/api/pi/claude-usage-range";
314
- else if (source === "codex") baseUrl = "/api/pi/codex-usage-range";
315
- else if (source === "opencode") baseUrl = "/api/pi/opencode-usage-range";
316
- else if (source === "gemini") baseUrl = "/api/pi/gemini-usage-range";
317
- else if (source === "grok") baseUrl = "/api/pi/grok-usage-range";
318
- else if (source === "atomcode") baseUrl = "/api/pi/atomcode-usage-range";
319
- else if (source === "copilot") baseUrl = "/api/pi/copilot-usage-range";
372
+ const baseUrl = source === "chatgpt"
373
+ ? "/api/pi/chatgpt-usage-range"
374
+ : "/api/pi/usage-range";
320
375
  // force=true adds refresh=1 so the API rescan bypasses its 30s session cache
321
376
  let url = `${baseUrl}?range=${range}${force ? "&refresh=1" : ""}`;
322
377
  if (range === "custom" && customFrom) {
@@ -391,6 +446,65 @@ export function DashboardPage() {
391
446
  const currentLogPage = Math.min(logPage, totalLogPages);
392
447
  const pagedLog = (data?.requestLog ?? []).slice((currentLogPage - 1) * LOG_PAGE_SIZE, currentLogPage * LOG_PAGE_SIZE);
393
448
 
449
+ const analytics = useMemo(() => {
450
+ const current = data ?? {
451
+ totalTokens: 0,
452
+ totalInput: 0,
453
+ totalOutput: 0,
454
+ totalCacheRead: 0,
455
+ totalCacheWrite: 0,
456
+ totalCost: 0,
457
+ totalRequests: 0,
458
+ cacheHitRate: 0,
459
+ dailyBreakdown: [],
460
+ hourlyBreakdown: [],
461
+ requestLog: [],
462
+ providerStats: [],
463
+ modelStats: [],
464
+ };
465
+ const totalTokens = Math.max(current.totalTokens, 0);
466
+ const totalRequests = Math.max(current.totalRequests, 0);
467
+ const cacheTokens = current.totalCacheRead + current.totalCacheWrite;
468
+ const activitySource = range === "today" ? current.hourlyBreakdown : current.dailyBreakdown;
469
+ const peak = activitySource.reduce<{ label: string; requests: number; tokens: number } | null>((best, row: any) => {
470
+ const requests = row.requests ?? 0;
471
+ const tokens = (row.input ?? 0) + (row.output ?? 0) + (row.cacheRead ?? 0) + (row.cacheWrite ?? 0);
472
+ const label = range === "today" ? String(row.hour ?? "").slice(-5) : formatDateShort(row.date || row.hour || "");
473
+ if (!best || requests > best.requests || (requests === best.requests && tokens > best.tokens)) {
474
+ return { label, requests, tokens };
475
+ }
476
+ return best;
477
+ }, null);
478
+ const providers = current.providerStats.filter((provider) => provider.totalRequests > 0).sort((a, b) => b.totalTokens - a.totalTokens);
479
+ const models = current.modelStats.filter((model) => model.totalRequests > 0).sort((a, b) => b.totalTokens - a.totalTokens);
480
+ const providerTokenTotal = providers.reduce((sum, provider) => sum + provider.totalTokens, 0);
481
+ const composition = [
482
+ { key: "input", value: current.totalInput, color: CHART_LINE_COLORS.input },
483
+ { key: "output", value: current.totalOutput, color: CHART_LINE_COLORS.output },
484
+ { key: "cacheRead", value: current.totalCacheRead, color: CHART_LINE_COLORS.cacheRead },
485
+ { key: "cacheWrite", value: current.totalCacheWrite, color: CHART_LINE_COLORS.cacheWrite },
486
+ ];
487
+ return {
488
+ totalTokens,
489
+ avgTokens: totalRequests > 0 ? totalTokens / totalRequests : 0,
490
+ avgCost: totalRequests > 0 ? current.totalCost / totalRequests : 0,
491
+ cacheTokens,
492
+ cacheShare: totalTokens > 0 ? (cacheTokens / totalTokens) * 100 : 0,
493
+ activeProviders: providers.length,
494
+ activeModels: models.length,
495
+ peak,
496
+ providers,
497
+ models,
498
+ providerTokenTotal,
499
+ composition,
500
+ activity: activitySource.map((row: any) => ({
501
+ label: range === "today" ? String(row.hour ?? "").slice(-5) : formatDateShort(row.date || row.hour || ""),
502
+ requests: row.requests ?? 0,
503
+ tokens: Math.round(((row.input ?? 0) + (row.output ?? 0) + (row.cacheRead ?? 0) + (row.cacheWrite ?? 0)) / 1000),
504
+ })),
505
+ };
506
+ }, [data, range]);
507
+
394
508
  const fmtCostCell = (v: number) => (currency === "CNY" ? `¥${(v * USD_TO_CNY).toFixed(4)}` : formatCostShort(v));
395
509
 
396
510
  const toggleSort = (setter: typeof setProviderSort) => (key: string) =>
@@ -414,10 +528,11 @@ export function DashboardPage() {
414
528
  };
415
529
 
416
530
  return (
417
- <div className="space-y-5">
531
+ <div className="dashboard-page space-y-5">
418
532
  {/* Title + Time Range Selector + Currency Toggle */}
419
- <div className="flex items-center justify-between flex-wrap gap-3">
533
+ <div className="dashboard-command-header flex items-center justify-between flex-wrap gap-3">
420
534
  <div>
535
+ <div className="page-kicker"><span /> TELEMETRY // LIVE OPERATIONS</div>
421
536
  <h1 className="text-xl font-bold" style={{ color: "var(--page-text)" }}>{t("dashboard.title")}</h1>
422
537
  <p className="text-xs mt-0.5" style={{ color: "var(--muted-text)" }}>
423
538
  {data ? t("dashboard.requests_count", String(data.requestLog.length), formatCost(data.totalCost, currency)) : ""}
@@ -526,6 +641,26 @@ export function DashboardPage() {
526
641
  </div>
527
642
  </div>
528
643
 
644
+ <div className="dashboard-source-strip tech-panel">
645
+ <span className="dashboard-source-label">{t("dashboard.data_source")}</span>
646
+ <div className="dashboard-source-options">
647
+ {([
648
+ ["pi", "dashboard.source_pi"],
649
+ ["chatgpt", "dashboard.source_chatgpt"],
650
+ ] as const).map(([key, label]) => (
651
+ <button
652
+ key={key}
653
+ onClick={() => setSource(key)}
654
+ className={cn("dashboard-source-option", source === key && "is-active")}
655
+ >
656
+ <span className="dashboard-source-dot" />
657
+ {t(label)}
658
+ </button>
659
+ ))}
660
+ </div>
661
+ {source === "chatgpt" && <span className="dashboard-source-note">{t("dashboard.source_chatgpt_note")}</span>}
662
+ </div>
663
+
529
664
  {/* Custom Date Picker */}
530
665
  {range === "custom" && (
531
666
  <div className="flex items-center gap-2 flex-wrap">
@@ -599,8 +734,187 @@ export function DashboardPage() {
599
734
  />
600
735
  </div>
601
736
 
737
+ {/* Rich analytics overview */}
738
+ <div className="dashboard-analytics-grid">
739
+ <section className="tech-panel analytics-overview-panel">
740
+ <div className="analytics-panel-header">
741
+ <div>
742
+ <span className="analytics-panel-kicker">SYSTEM READOUT // 01</span>
743
+ <h3>{t("dashboard.operational_overview")}</h3>
744
+ </div>
745
+ <Gauge className="h-4 w-4" style={{ color: "var(--signal-cyan)" }} />
746
+ </div>
747
+ <div className="analytics-metrics-grid">
748
+ <AnalyticsMetric
749
+ label={t("dashboard.avg_tokens_request")}
750
+ value={formatTokensShort(Math.round(analytics.avgTokens), lang)}
751
+ detail={t("dashboard.avg_tokens_request_detail", String(analytics.activeModels))}
752
+ icon={<Activity className="h-4 w-4" />}
753
+ />
754
+ <AnalyticsMetric
755
+ label={t("dashboard.avg_cost_request")}
756
+ value={fmtCostCell(analytics.avgCost)}
757
+ detail={t("dashboard.avg_cost_request_detail", String(analytics.activeProviders))}
758
+ icon={<CircleDollarSign className="h-4 w-4" />}
759
+ accent="var(--signal-amber)"
760
+ />
761
+ <AnalyticsMetric
762
+ label={t("dashboard.peak_activity")}
763
+ value={analytics.peak?.label || "—"}
764
+ detail={analytics.peak ? t("dashboard.peak_activity_detail", String(analytics.peak.requests)) : t("dashboard.no_data")}
765
+ icon={<Zap className="h-4 w-4" />}
766
+ accent="var(--signal-lime)"
767
+ />
768
+ <AnalyticsMetric
769
+ label={t("dashboard.active_models")}
770
+ value={String(analytics.activeModels)}
771
+ detail={t("dashboard.active_models_detail", String(analytics.activeProviders))}
772
+ icon={<Cpu className="h-4 w-4" />}
773
+ accent="var(--signal-violet)"
774
+ />
775
+ </div>
776
+ </section>
777
+
778
+ <section className="tech-panel composition-panel">
779
+ <div className="analytics-panel-header">
780
+ <div>
781
+ <span className="analytics-panel-kicker">TOKEN FLOW // 02</span>
782
+ <h3>{t("dashboard.token_composition")}</h3>
783
+ </div>
784
+ <PieChartIcon className="h-4 w-4" style={{ color: "var(--signal-violet)" }} />
785
+ </div>
786
+ <div className="composition-layout">
787
+ <div className="composition-chart">
788
+ <ResponsiveContainer width="100%" height="100%">
789
+ <PieChart>
790
+ <Pie
791
+ data={analytics.composition}
792
+ dataKey="value"
793
+ nameKey="key"
794
+ innerRadius="62%"
795
+ outerRadius="86%"
796
+ paddingAngle={3}
797
+ stroke="none"
798
+ >
799
+ {analytics.composition.map((entry) => <Cell key={entry.key} fill={entry.color} />)}
800
+ </Pie>
801
+ <Tooltip
802
+ formatter={(value: any) => formatTokensShort(Number(Array.isArray(value) ? value[0] : value ?? 0), lang)}
803
+ contentStyle={{ backgroundColor: "var(--card-bg-solid)", border: "1px solid var(--card-border)", borderRadius: "8px", fontSize: "11px" }}
804
+ />
805
+ </PieChart>
806
+ </ResponsiveContainer>
807
+ <div className="composition-center">
808
+ <strong>{formatTokensShort(analytics.cacheTokens, lang)}</strong>
809
+ <span>{t("dashboard.cache_total")}</span>
810
+ </div>
811
+ </div>
812
+ <div className="composition-legend">
813
+ {analytics.composition.map((entry) => {
814
+ const pct = analytics.totalTokens > 0 ? (entry.value / analytics.totalTokens) * 100 : 0;
815
+ const labels: Record<string, string> = {
816
+ input: t("dashboard.input"),
817
+ output: t("dashboard.output"),
818
+ cacheRead: t("dashboard.cache_hit"),
819
+ cacheWrite: t("dashboard.cache_create"),
820
+ };
821
+ return (
822
+ <div key={entry.key} className="composition-legend-row">
823
+ <span className="legend-dot" style={{ backgroundColor: entry.color }} />
824
+ <span>{labels[entry.key]}</span>
825
+ <strong>{pct.toFixed(1)}%</strong>
826
+ </div>
827
+ );
828
+ })}
829
+ <div className="composition-summary">
830
+ <span>{t("dashboard.cache_hit_rate")}</span>
831
+ <strong>{analytics.cacheShare.toFixed(1)}%</strong>
832
+ </div>
833
+ </div>
834
+ </div>
835
+ </section>
836
+
837
+ <section className="tech-panel distribution-panel">
838
+ <div className="analytics-panel-header">
839
+ <div>
840
+ <span className="analytics-panel-kicker">ROUTING MATRIX // 03</span>
841
+ <h3>{t("dashboard.provider_mix")}</h3>
842
+ </div>
843
+ <Layers3 className="h-4 w-4" style={{ color: "var(--signal-amber)" }} />
844
+ </div>
845
+ <div className="distribution-list">
846
+ {analytics.providers.length === 0 ? (
847
+ <p className="analytics-no-data">{t("dashboard.no_data")}</p>
848
+ ) : analytics.providers.slice(0, 5).map((provider, index) => (
849
+ <DistributionRow
850
+ key={provider.providerId}
851
+ name={provider.providerId}
852
+ meta={t("dashboard.provider_models", String(provider.modelCount))}
853
+ value={provider.totalTokens}
854
+ percentage={analytics.providerTokenTotal > 0 ? (provider.totalTokens / analytics.providerTokenTotal) * 100 : 0}
855
+ valueLabel={formatTokensShort(provider.totalTokens, lang)}
856
+ color={COLORS[index % COLORS.length] ?? COLORS[0]!}
857
+ />
858
+ ))}
859
+ </div>
860
+ </section>
861
+
862
+ <section className="tech-panel activity-panel">
863
+ <div className="analytics-panel-header">
864
+ <div>
865
+ <span className="analytics-panel-kicker">REQUEST PULSE // 04</span>
866
+ <h3>{t("dashboard.request_activity")}</h3>
867
+ </div>
868
+ <Clock3 className="h-4 w-4" style={{ color: "var(--signal-lime)" }} />
869
+ </div>
870
+ <div className="activity-chart">
871
+ <ResponsiveContainer width="100%" height="100%">
872
+ <BarChart data={analytics.activity} margin={{ top: 8, right: 4, left: -22, bottom: 0 }}>
873
+ <CartesianGrid vertical={false} stroke="var(--card-border)" strokeDasharray="3 3" />
874
+ <XAxis dataKey="label" tick={{ fontSize: 9, fill: "var(--muted-text)" }} axisLine={false} tickLine={false} interval="preserveStartEnd" />
875
+ <YAxis allowDecimals={false} tick={{ fontSize: 9, fill: "var(--muted-text)" }} axisLine={false} tickLine={false} />
876
+ <Tooltip
877
+ cursor={{ fill: "color-mix(in srgb, var(--signal-cyan) 6%, transparent)" }}
878
+ formatter={(value: any) => [Number(Array.isArray(value) ? value[0] : value ?? 0), t("dashboard.requests")]}
879
+ contentStyle={{ backgroundColor: "var(--card-bg-solid)", border: "1px solid var(--card-border)", borderRadius: "8px", fontSize: "11px" }}
880
+ />
881
+ <Bar dataKey="requests" fill="var(--signal-cyan)" radius={[3, 3, 0, 0]} maxBarSize={18} />
882
+ </BarChart>
883
+ </ResponsiveContainer>
884
+ </div>
885
+ <div className="activity-footer">
886
+ <span>{t("dashboard.total_requests")}</span>
887
+ <strong>{formatNumber(data.totalRequests)}</strong>
888
+ </div>
889
+ </section>
890
+
891
+ <section className="tech-panel model-leaderboard-panel">
892
+ <div className="analytics-panel-header">
893
+ <div>
894
+ <span className="analytics-panel-kicker">MODEL LOAD // 05</span>
895
+ <h3>{t("dashboard.model_leaderboard")}</h3>
896
+ </div>
897
+ <Cpu className="h-4 w-4" style={{ color: "var(--signal-violet)" }} />
898
+ </div>
899
+ <div className="leaderboard-list">
900
+ {analytics.models.length === 0 ? (
901
+ <p className="analytics-no-data">{t("dashboard.no_data")}</p>
902
+ ) : analytics.models.slice(0, 5).map((model, index) => (
903
+ <div key={`${model.providerId}/${model.modelId}`} className="leaderboard-row">
904
+ <span className="leaderboard-rank">0{index + 1}</span>
905
+ <div className="min-w-0 flex-1">
906
+ <div className="leaderboard-name">{model.modelId}</div>
907
+ <div className="leaderboard-meta">{model.providerId} · {formatNumber(model.totalRequests)} {t("dashboard.requests")}</div>
908
+ </div>
909
+ <span className="leaderboard-value">{formatTokensShort(model.totalTokens, lang)}</span>
910
+ </div>
911
+ ))}
912
+ </div>
913
+ </section>
914
+ </div>
915
+
602
916
  {/* Usage Trend Chart */}
603
- <div className="rounded-xl border p-5" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--card-bg)" }}>
917
+ <div className="tech-panel telemetry-chart p-5" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--card-bg)" }}>
604
918
  <h3 className="text-sm font-semibold mb-1" style={{ color: "var(--page-text)" }}>{t("dashboard.usage_trend")}</h3>
605
919
  <p className="text-xs mb-4" style={{ color: "var(--muted-text)" }}>
606
920
  {range === "today" ? t("dashboard.range.today") : `${formatDateShort(chartData[0]?.rawDate || "")} - ${formatDateShort(chartData[chartData.length - 1]?.rawDate || "")}`}
@@ -633,7 +947,7 @@ export function DashboardPage() {
633
947
  </div>
634
948
 
635
949
  {/* Tabs: Request Log / Provider / Model */}
636
- <div className="rounded-xl border overflow-hidden" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--card-bg)" }}>
950
+ <div className="tech-panel data-console overflow-hidden" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--card-bg)" }}>
637
951
  {/* Tab Header */}
638
952
  <div className="flex items-center border-b" style={{ borderColor: "var(--card-border)" }}>
639
953
  {([
@@ -649,8 +963,8 @@ export function DashboardPage() {
649
963
  tab === tabItem.key ? "" : "border-transparent"
650
964
  )}
651
965
  style={{
652
- color: tab === tabItem.key ? "#3b82f6" : "var(--muted-text)",
653
- borderBottomColor: tab === tabItem.key ? "#3b82f6" : "transparent",
966
+ color: tab === tabItem.key ? "var(--signal-cyan)" : "var(--muted-text)",
967
+ borderBottomColor: tab === tabItem.key ? "var(--signal-cyan)" : "transparent",
654
968
  }}
655
969
  >
656
970
  {t(tabItem.label)}
@@ -1,25 +1,57 @@
1
+ import { useState } from "react";
1
2
  import { Outlet, useLocation } from "react-router-dom";
3
+ import { Menu, RadioTower } from "lucide-react";
2
4
  import { Sidebar } from "./Sidebar";
3
5
 
4
6
  export function AppShell() {
5
7
  const location = useLocation();
8
+ const [mobileNavOpen, setMobileNavOpen] = useState(false);
6
9
  const isFullHeightPage = location.pathname.startsWith("/chat");
7
10
 
8
11
  return (
9
- <div className="flex h-screen overflow-hidden">
10
- <Sidebar />
11
- <main
12
- className={isFullHeightPage ? "flex-1 overflow-hidden" : "flex-1 overflow-y-auto"}
13
- style={{ backgroundColor: "var(--page-bg)" }}
14
- >
15
- {isFullHeightPage ? (
16
- <Outlet />
17
- ) : (
18
- <div className="mx-auto max-w-7xl px-8 py-8">
19
- <Outlet />
12
+ <div className="app-shell">
13
+ <div className="app-atmosphere" aria-hidden="true">
14
+ <span className="app-orbit app-orbit-one" />
15
+ <span className="app-orbit app-orbit-two" />
16
+ <span className="app-scanline" />
17
+ </div>
18
+
19
+ <Sidebar mobileOpen={mobileNavOpen} onClose={() => setMobileNavOpen(false)} />
20
+
21
+ {mobileNavOpen && (
22
+ <button
23
+ aria-label="Close navigation"
24
+ className="sidebar-scrim"
25
+ onClick={() => setMobileNavOpen(false)}
26
+ />
27
+ )}
28
+
29
+ <div className="app-stage">
30
+ <header className="mobile-command-bar">
31
+ <button
32
+ className="command-icon-button"
33
+ aria-label="Open navigation"
34
+ onClick={() => setMobileNavOpen(true)}
35
+ >
36
+ <Menu className="h-5 w-5" />
37
+ </button>
38
+ <div className="mobile-brand">
39
+ <RadioTower className="h-4 w-4" />
40
+ <span>PI // CONTROL</span>
20
41
  </div>
21
- )}
22
- </main>
42
+ <span className="system-pulse" aria-hidden="true" />
43
+ </header>
44
+
45
+ <main className={isFullHeightPage ? "app-main app-main-full" : "app-main"}>
46
+ {isFullHeightPage ? (
47
+ <Outlet />
48
+ ) : (
49
+ <div className="app-canvas">
50
+ <Outlet />
51
+ </div>
52
+ )}
53
+ </main>
54
+ </div>
23
55
  </div>
24
56
  );
25
57
  }