rogasper commited on
Commit
1798222
·
1 Parent(s): 85cb661

feat: add API key management components including ApiKeyForm, ApiKeyList, SecurityInfo, TipsCard, and TokenUsageChart. Enhance settings route to integrate these components for improved API key configuration and token usage tracking. Implement backend support for token usage history with new query options.

Browse files
apps/web/src/components/settings/ApiKeyForm.tsx ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type ApiKeyConfig } from "@/hooks/use-api-key";
2
+ import { Button } from "@labas/ui/components/button";
3
+ import { Input } from "@labas/ui/components/input";
4
+ import { Label } from "@labas/ui/components/label";
5
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
6
+ import {
7
+ Select,
8
+ SelectContent,
9
+ SelectGroup,
10
+ SelectItem,
11
+ SelectTrigger,
12
+ SelectValue,
13
+ } from "@labas/ui/components/select";
14
+
15
+ type FormState = Omit<ApiKeyConfig, "id"> & { apiKey: string };
16
+
17
+ interface ProviderOption {
18
+ value: string;
19
+ label: string;
20
+ }
21
+
22
+ interface ApiKeyFormProps {
23
+ isAdding: boolean;
24
+ isSaving: boolean;
25
+ form: FormState;
26
+ canSave: boolean;
27
+ providers: ProviderOption[];
28
+ onChange: (field: keyof FormState, value: string | number) => void;
29
+ onSave: () => void;
30
+ onCancel: () => void;
31
+ }
32
+
33
+ export function ApiKeyForm({ isAdding, isSaving, form, canSave, providers, onChange, onSave, onCancel }: ApiKeyFormProps) {
34
+ return (
35
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--matcha-400)] rounded-[var(--radius-xl)]">
36
+ <CardHeader>
37
+ <CardTitle className="font-headline text-[var(--clay-black)]">
38
+ {isAdding ? "Tambah API Key Baru" : "Edit API Key"}
39
+ </CardTitle>
40
+ <CardDescription className="text-[var(--warm-charcoal)]">
41
+ {isAdding
42
+ ? "Tambahkan konfigurasi provider baru."
43
+ : "Kosongkan field API Key jika tidak ingin mengubahnya."}
44
+ </CardDescription>
45
+ </CardHeader>
46
+ <CardContent className="space-y-5">
47
+ <div className="space-y-2">
48
+ <Label htmlFor="name" className="text-[var(--clay-black)]">Nama Config</Label>
49
+ <Input
50
+ id="name"
51
+ value={form.name}
52
+ onChange={(e) => onChange("name", e.target.value)}
53
+ placeholder="Contoh: OpenAI GPT-4o"
54
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
55
+ />
56
+ </div>
57
+
58
+ <div className="grid grid-cols-2 gap-4">
59
+ <div className="space-y-2">
60
+ <Label htmlFor="provider" className="text-[var(--clay-black)]">Provider</Label>
61
+ <Select
62
+ items={providers}
63
+ value={form.provider}
64
+ onValueChange={(v: string | null) => onChange("provider", v ?? "openai")}
65
+ >
66
+ <SelectTrigger id="provider">
67
+ <SelectValue placeholder="Pilih provider" />
68
+ </SelectTrigger>
69
+ <SelectContent>
70
+ <SelectGroup>
71
+ {providers.map((p) => (
72
+ <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
73
+ ))}
74
+ </SelectGroup>
75
+ </SelectContent>
76
+ </Select>
77
+ </div>
78
+ <div className="space-y-2">
79
+ <Label htmlFor="model" className="text-[var(--clay-black)]">Model</Label>
80
+ <Input
81
+ id="model"
82
+ value={form.modelName}
83
+ onChange={(e) => onChange("modelName", e.target.value)}
84
+ placeholder="gpt-4o-mini"
85
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
86
+ />
87
+ </div>
88
+ </div>
89
+
90
+ <div className="grid grid-cols-2 gap-4">
91
+ <div className="space-y-2">
92
+ <Label htmlFor="baseUrl" className="text-[var(--clay-black)]">Base URL</Label>
93
+ <Input
94
+ id="baseUrl"
95
+ value={form.baseUrl}
96
+ onChange={(e) => onChange("baseUrl", e.target.value)}
97
+ placeholder="https://api.openai.com/v1"
98
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
99
+ />
100
+ </div>
101
+ <div className="space-y-2">
102
+ <Label htmlFor="maxTokens" className="text-[var(--clay-black)]">Max Tokens</Label>
103
+ <Input
104
+ id="maxTokens"
105
+ type="number"
106
+ min={1}
107
+ max={1_000_000}
108
+ value={form.maxTokens}
109
+ onChange={(e) => onChange("maxTokens", Number(e.target.value))}
110
+ placeholder="16384"
111
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
112
+ />
113
+ </div>
114
+ </div>
115
+
116
+ <div className="space-y-2">
117
+ <Label htmlFor="apiKey" className="text-[var(--clay-black)]">
118
+ API Key
119
+ {!isAdding && (
120
+ <span className="text-[var(--warm-silver)] font-normal ml-1">
121
+ (kosongkan jika tidak diubah)
122
+ </span>
123
+ )}
124
+ </Label>
125
+ <Input
126
+ id="apiKey"
127
+ type="password"
128
+ value={form.apiKey}
129
+ onChange={(e) => onChange("apiKey", e.target.value)}
130
+ placeholder={isAdding ? "sk-..." : "•••••••• (kosongkan untuk tidak mengubah)"}
131
+ className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
132
+ />
133
+ </div>
134
+
135
+ <div className="flex gap-3">
136
+ <Button
137
+ onClick={onSave}
138
+ disabled={!canSave || isSaving}
139
+ className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] h-11 clay-shadow clay-hover"
140
+ >
141
+ {isSaving ? "Menyimpan..." : isAdding ? "Simpan" : "Update"}
142
+ </Button>
143
+ <Button
144
+ variant="outline"
145
+ onClick={onCancel}
146
+ className="rounded-[var(--radius-lg)] h-11 border-2 border-[var(--oat-border)]"
147
+ >
148
+ Batal
149
+ </Button>
150
+ </div>
151
+ </CardContent>
152
+ </Card>
153
+ );
154
+ }
apps/web/src/components/settings/ApiKeyList.tsx ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type ApiKeyConfig } from "@/hooks/use-api-key";
2
+ import { Button } from "@labas/ui/components/button";
3
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
4
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
5
+
6
+ interface ProviderOption {
7
+ value: string;
8
+ label: string;
9
+ }
10
+
11
+ interface ApiKeyListProps {
12
+ configs: ApiKeyConfig[];
13
+ isLoading: boolean;
14
+ editingId: string | null;
15
+ isFormOpen: boolean;
16
+ providers: ProviderOption[];
17
+ onStartAdd: () => void;
18
+ onStartEdit: (config: ApiKeyConfig) => void;
19
+ onRemove: (id: string) => void;
20
+ }
21
+
22
+ export function ApiKeyList({
23
+ configs,
24
+ isLoading,
25
+ editingId,
26
+ isFormOpen,
27
+ providers,
28
+ onStartAdd,
29
+ onStartEdit,
30
+ onRemove,
31
+ }: ApiKeyListProps) {
32
+ return (
33
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
34
+ <CardHeader>
35
+ <div className="flex items-center justify-between">
36
+ <div className="flex items-center gap-3">
37
+ <MaterialIcon name="key" className="text-xl" />
38
+ <div>
39
+ <CardTitle className="font-headline text-[var(--clay-black)]">API Keys</CardTitle>
40
+ <CardDescription className="text-[var(--warm-charcoal)]">
41
+ {configs.length} konfigurasi tersimpan
42
+ </CardDescription>
43
+ </div>
44
+ </div>
45
+ {!isFormOpen && (
46
+ <Button
47
+ onClick={onStartAdd}
48
+ className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] clay-shadow clay-hover text-sm"
49
+ >
50
+ <MaterialIcon name="add" className="mr-1" />
51
+ Tambah
52
+ </Button>
53
+ )}
54
+ </div>
55
+ </CardHeader>
56
+ <CardContent className="space-y-4">
57
+ {isLoading ? (
58
+ <div className="h-20 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-lg)]" />
59
+ ) : configs.length === 0 ? (
60
+ <div className="text-center py-8">
61
+ <MaterialIcon name="key_off" className="text-4xl text-[var(--warm-silver)] mx-auto mb-3" />
62
+ <p className="text-[var(--warm-charcoal)] font-semibold">Belum ada API key</p>
63
+ <p className="text-xs text-[var(--warm-silver)] mt-1">
64
+ Tambahkan minimal satu key untuk generate soal.
65
+ </p>
66
+ <Button
67
+ onClick={onStartAdd}
68
+ className="mt-4 bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]"
69
+ >
70
+ <MaterialIcon name="add" className="mr-1" />
71
+ Tambah API Key
72
+ </Button>
73
+ </div>
74
+ ) : (
75
+ <div className="space-y-3">
76
+ {configs.map((config) => {
77
+ const isEditing = editingId === config.id;
78
+ return (
79
+ <div
80
+ key={config.id}
81
+ className={`p-4 rounded-[var(--radius-lg)] border-2 transition-colors ${
82
+ isEditing
83
+ ? "border-[var(--matcha-600)] bg-[var(--matcha-100)]"
84
+ : "border-[var(--oat-border)] bg-[var(--warm-cream)]"
85
+ }`}
86
+ >
87
+ <div className="flex items-center justify-between">
88
+ <div className="min-w-0">
89
+ <div className="flex items-center gap-2 mb-1">
90
+ <span className="font-semibold text-[var(--clay-black)] truncate">
91
+ {config.name}
92
+ </span>
93
+ <span className="px-2 py-0.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-[10px] font-semibold">
94
+ {providers.find((p) => p.value === config.provider)?.label ?? config.provider}
95
+ </span>
96
+ </div>
97
+ <div className="text-xs text-[var(--warm-charcoal)]">
98
+ {config.modelName} · {config.baseUrl.replace("https://", "").replace("/v1", "")}
99
+ </div>
100
+ </div>
101
+ <div className="flex gap-2 shrink-0">
102
+ <button
103
+ onClick={() => onStartEdit(config)}
104
+ className="p-2 rounded-[var(--radius-md)] hover:bg-[var(--oat-light)] text-[var(--warm-charcoal)] transition-colors"
105
+ title="Edit"
106
+ >
107
+ <MaterialIcon name="edit" className="text-sm" />
108
+ </button>
109
+ <button
110
+ onClick={() => {
111
+ if (confirm("Yakin mau hapus konfigurasi ini?")) {
112
+ onRemove(config.id);
113
+ }
114
+ }}
115
+ className="p-2 rounded-[var(--radius-md)] hover:bg-[var(--pomegranate-400)]/10 text-[var(--pomegranate-400)] transition-colors"
116
+ title="Hapus"
117
+ >
118
+ <MaterialIcon name="delete" className="text-sm" />
119
+ </button>
120
+ </div>
121
+ </div>
122
+ </div>
123
+ );
124
+ })}
125
+ </div>
126
+ )}
127
+ </CardContent>
128
+ </Card>
129
+ );
130
+ }
apps/web/src/components/settings/SecurityInfo.tsx ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
2
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
3
+
4
+ export function SecurityInfo() {
5
+ return (
6
+ <div className="max-w-xl">
7
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
8
+ <CardHeader>
9
+ <CardTitle className="font-headline text-[var(--clay-black)]">Keamanan</CardTitle>
10
+ <CardDescription className="text-[var(--warm-charcoal)]">
11
+ Cara penyimpanan API key Anda.
12
+ </CardDescription>
13
+ </CardHeader>
14
+ <CardContent className="space-y-4">
15
+ <div className="flex items-center gap-3 p-4 bg-[var(--matcha-300)] rounded-[var(--radius-lg)]">
16
+ <MaterialIcon name="lock" className="text-xl text-[var(--matcha-800)]" />
17
+ <div>
18
+ <p className="font-medium text-[var(--matcha-800)]">Enkripsi Lokal</p>
19
+ <p className="text-sm text-[var(--matcha-800)]/70">
20
+ AES-GCM-256 dengan key di IndexedDB
21
+ </p>
22
+ </div>
23
+ </div>
24
+ <div className="text-xs text-[var(--warm-charcoal)] space-y-2">
25
+ <p className="flex items-center gap-2">
26
+ <MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
27
+ API key tidak pernah dikirim ke server kami
28
+ </p>
29
+ <p className="flex items-center gap-2">
30
+ <MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
31
+ Data terenkripsi di localStorage browser Anda
32
+ </p>
33
+ <p className="flex items-center gap-2">
34
+ <MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
35
+ Key enkripsi tersimpan aman di IndexedDB
36
+ </p>
37
+ <p className="flex items-center gap-2">
38
+ <MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
39
+ BYOK — Bring Your Own Key, platform tidak menyimpan key
40
+ </p>
41
+ </div>
42
+ </CardContent>
43
+ </Card>
44
+ </div>
45
+ );
46
+ }
apps/web/src/components/settings/TipsCard.tsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
2
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
3
+
4
+ export function TipsCard() {
5
+ return (
6
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] sticky top-8">
7
+ <CardHeader>
8
+ <CardTitle className="font-headline text-[var(--clay-black)]">Tips</CardTitle>
9
+ <CardDescription className="text-[var(--warm-charcoal)]">
10
+ Panduan singkat mengelola API key.
11
+ </CardDescription>
12
+ </CardHeader>
13
+ <CardContent className="space-y-4">
14
+ <div className="text-xs text-[var(--warm-charcoal)] space-y-2">
15
+ <p className="flex items-start gap-2">
16
+ <MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
17
+ BYOK — bawa key milik Anda sendiri
18
+ </p>
19
+ <p className="flex items-start gap-2">
20
+ <MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
21
+ Platform tidak menyimpan key di server
22
+ </p>
23
+ <p className="flex items-start gap-2">
24
+ <MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
25
+ Key dienkripsi secara lokal di browser
26
+ </p>
27
+ <p className="flex items-start gap-2">
28
+ <MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
29
+ Anda bisa menambahkan beberapa config
30
+ </p>
31
+ </div>
32
+ </CardContent>
33
+ </Card>
34
+ );
35
+ }
apps/web/src/components/settings/TokenUsageChart.tsx ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+ import {
3
+ AreaChart,
4
+ Area,
5
+ XAxis,
6
+ YAxis,
7
+ CartesianGrid,
8
+ Tooltip,
9
+ ResponsiveContainer,
10
+ Legend,
11
+ } from "recharts";
12
+ import { useQuery } from "@tanstack/react-query";
13
+ import { trpc } from "@/utils/trpc";
14
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@labas/ui/components/card";
15
+ import {
16
+ Tabs,
17
+ TabsList,
18
+ TabsTrigger,
19
+ } from "@labas/ui/components/tabs";
20
+ import { MaterialIcon } from "@/components/ui/MaterialIcon";
21
+
22
+ type Period = "daily" | "weekly" | "monthly";
23
+
24
+ interface ChartDataPoint {
25
+ date: string;
26
+ label: string;
27
+ totalTokens: number;
28
+ jobCount: number;
29
+ }
30
+
31
+ function formatTokenValue(value: number): string {
32
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
33
+ if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
34
+ return value.toString();
35
+ }
36
+
37
+ export function TokenUsageChart() {
38
+ const [period, setPeriod] = useState<Period>("daily");
39
+
40
+ const { data, isLoading } = useQuery(
41
+ trpc.ai.tokenUsageHistory.queryOptions({ period }),
42
+ );
43
+
44
+ const chartData: ChartDataPoint[] = (data ?? []).map((d) => ({
45
+ date: d.date,
46
+ label: d.label,
47
+ totalTokens: d.totalTokens,
48
+ jobCount: d.jobCount,
49
+ }));
50
+
51
+ const hasData = chartData.some((d) => d.totalTokens > 0 || d.jobCount > 0);
52
+
53
+ return (
54
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
55
+ <CardHeader>
56
+ <div className="flex items-center justify-between">
57
+ <div className="flex items-center gap-3">
58
+ <MaterialIcon name="show_chart" className="text-xl" />
59
+ <div>
60
+ <CardTitle className="font-headline text-[var(--clay-black)]">
61
+ Tren Penggunaan Token
62
+ </CardTitle>
63
+ <CardDescription className="text-[var(--warm-charcoal)]">
64
+ {period === "daily" && "7 hari terakhir"}
65
+ {period === "weekly" && "4 minggu terakhir"}
66
+ {period === "monthly" && "6 bulan terakhir"}
67
+ </CardDescription>
68
+ </div>
69
+ </div>
70
+ <Tabs
71
+ value={period}
72
+ onValueChange={(v: string | null) => setPeriod((v as Period) ?? "daily")}
73
+ >
74
+ <TabsList variant="line" className="h-8">
75
+ <TabsTrigger value="daily" className="text-xs px-3">Harian</TabsTrigger>
76
+ <TabsTrigger value="weekly" className="text-xs px-3">Mingguan</TabsTrigger>
77
+ <TabsTrigger value="monthly" className="text-xs px-3">Bulanan</TabsTrigger>
78
+ </TabsList>
79
+ </Tabs>
80
+ </div>
81
+ </CardHeader>
82
+ <CardContent>
83
+ {isLoading ? (
84
+ <div className="h-64 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-lg)]" />
85
+ ) : !hasData ? (
86
+ <div className="h-64 flex flex-col items-center justify-center text-[var(--warm-charcoal)]">
87
+ <MaterialIcon name="receipt_long" className="text-4xl text-[var(--warm-silver)] mb-3" />
88
+ <p className="font-semibold">Belum ada data penggunaan</p>
89
+ <p className="text-xs text-[var(--warm-silver)] mt-1">
90
+ Generate soal untuk melihat statistik penggunaan token.
91
+ </p>
92
+ </div>
93
+ ) : (
94
+ <ResponsiveContainer width="100%" height={256}>
95
+ <AreaChart data={chartData} margin={{ top: 5, right: 10, left: -10, bottom: 0 }}>
96
+ <defs>
97
+ <linearGradient id="tokenGradient" x1="0" y1="0" x2="0" y2="1">
98
+ <stop offset="5%" stopColor="var(--chart-1)" stopOpacity={0.3} />
99
+ <stop offset="95%" stopColor="var(--chart-1)" stopOpacity={0} />
100
+ </linearGradient>
101
+ <linearGradient id="jobGradient" x1="0" y1="0" x2="0" y2="1">
102
+ <stop offset="5%" stopColor="var(--chart-2)" stopOpacity={0.3} />
103
+ <stop offset="95%" stopColor="var(--chart-2)" stopOpacity={0} />
104
+ </linearGradient>
105
+ </defs>
106
+ <CartesianGrid strokeDasharray="3 3" stroke="var(--oat-border)" />
107
+ <XAxis
108
+ dataKey="label"
109
+ tick={{ fontSize: 12, fill: "var(--warm-charcoal)" }}
110
+ axisLine={{ stroke: "var(--oat-border)" }}
111
+ tickLine={false}
112
+ />
113
+ <YAxis
114
+ yAxisId="left"
115
+ tick={{ fontSize: 12, fill: "var(--warm-charcoal)" }}
116
+ axisLine={false}
117
+ tickLine={false}
118
+ tickFormatter={formatTokenValue}
119
+ />
120
+ <YAxis
121
+ yAxisId="right"
122
+ orientation="right"
123
+ tick={{ fontSize: 12, fill: "var(--warm-charcoal)" }}
124
+ axisLine={false}
125
+ tickLine={false}
126
+ />
127
+ <Tooltip
128
+ contentStyle={{
129
+ backgroundColor: "var(--pure-white)",
130
+ border: "2px solid var(--oat-border)",
131
+ borderRadius: "var(--radius-lg)",
132
+ fontSize: 13,
133
+ }}
134
+ formatter={(value: unknown, name: unknown) => {
135
+ const num = typeof value === "number" ? value : 0;
136
+ const n = typeof name === "string" ? name : "";
137
+ if (n === "totalTokens") return [num.toLocaleString("id-ID"), "Token"];
138
+ if (n === "jobCount") return [num, "Job"];
139
+ return [num, n];
140
+ }}
141
+ />
142
+ <Legend
143
+ wrapperStyle={{ fontSize: 12, paddingTop: 8 }}
144
+ formatter={(v: string) => {
145
+ if (v === "totalTokens") return "Token";
146
+ if (v === "jobCount") return "Job";
147
+ return v;
148
+ }}
149
+ />
150
+ <Area
151
+ yAxisId="left"
152
+ type="monotone"
153
+ dataKey="totalTokens"
154
+ stroke="var(--chart-1)"
155
+ strokeWidth={2}
156
+ fill="url(#tokenGradient)"
157
+ name="totalTokens"
158
+ />
159
+ <Area
160
+ yAxisId="right"
161
+ type="monotone"
162
+ dataKey="jobCount"
163
+ stroke="var(--chart-2)"
164
+ strokeWidth={2}
165
+ fill="url(#jobGradient)"
166
+ name="jobCount"
167
+ />
168
+ </AreaChart>
169
+ </ResponsiveContainer>
170
+ )}
171
+ </CardContent>
172
+ </Card>
173
+ );
174
+ }
apps/web/src/routes/settings.tsx CHANGED
@@ -1,31 +1,29 @@
1
- import { useState } from "react";
2
  import { createFileRoute, redirect, Link } from "@tanstack/react-router";
3
  import { useQuery } from "@tanstack/react-query";
4
  import { authClient } from "@/lib/auth-client";
5
  import { useApiKeys, type ApiKeyConfig } from "@/hooks/use-api-key";
6
  import { trpc } from "@/utils/trpc";
7
- import { Button } from "@labas/ui/components/button";
8
- import { Input } from "@labas/ui/components/input";
9
- import { Label } from "@labas/ui/components/label";
10
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
11
  import {
12
  Tabs,
13
  TabsList,
14
  TabsTrigger,
15
  TabsContent,
16
  } from "@labas/ui/components/tabs";
17
- import {
18
- Select,
19
- SelectContent,
20
- SelectGroup,
21
- SelectItem,
22
- SelectTrigger,
23
- SelectValue,
24
- } from "@labas/ui/components/select";
25
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
 
 
 
 
 
 
26
 
27
  export const Route = createFileRoute("/settings")({
28
  component: RouteComponent,
 
 
 
29
  beforeLoad: async () => {
30
  const session = await authClient.getSession();
31
  if (!session.data) {
@@ -54,6 +52,8 @@ function defaultConfig(): Omit<ApiKeyConfig, "id" | "apiKey"> {
54
  };
55
  }
56
 
 
 
57
  function TokenUsageSection() {
58
  const { data, isLoading } = useQuery(trpc.ai.tokenUsageToday.queryOptions());
59
 
@@ -61,130 +61,134 @@ function TokenUsageSection() {
61
  const jobs = data?.jobs ?? [];
62
 
63
  return (
64
- <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
65
- <CardHeader>
66
- <div className="flex items-center gap-3">
67
- <MaterialIcon name="toll" className="text-xl" />
68
- <div>
69
- <CardTitle className="font-headline text-[var(--clay-black)]">
70
- Penggunaan Token Hari Ini
71
- </CardTitle>
72
- <CardDescription className="text-[var(--warm-charcoal)]">
73
- Total token yang terpakai untuk generate soal hari ini.
74
- </CardDescription>
75
- </div>
76
- </div>
77
- </CardHeader>
78
- <CardContent className="space-y-6">
79
- <div className="flex items-center gap-4 p-4 bg-[var(--matcha-300)] rounded-[var(--radius-lg)]">
80
- <MaterialIcon name="toll" className="text-3xl text-[var(--matcha-800)]" />
81
- <div>
82
- <p className="text-sm text-[var(--matcha-800)]/80">Total Token Terpakai</p>
83
- <p className="text-3xl font-extrabold text-[var(--matcha-800)]">
84
- {isLoading ? "..." : totalTokens.toLocaleString("id-ID")}
85
- </p>
86
- </div>
87
- </div>
88
 
89
- <div>
90
- <h3 className="text-sm font-semibold text-[var(--clay-black)] mb-3">
91
- Riwayat Generate Hari Ini
92
- </h3>
93
- {isLoading ? (
94
- <div className="space-y-2">
95
- {[1, 2, 3].map((i) => (
96
- <div key={i} className="h-12 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-lg)]" />
97
- ))}
 
 
98
  </div>
99
- ) : jobs.length === 0 ? (
100
- <div className="text-center py-8 border-2 border-dashed border-[var(--oat-border)] rounded-[var(--radius-lg)]">
101
- <MaterialIcon name="receipt_long" className="text-4xl text-[var(--warm-silver)] mx-auto mb-3" />
102
- <p className="text-[var(--warm-charcoal)] font-semibold">Belum ada generate hari ini</p>
103
- <p className="text-xs text-[var(--warm-silver)] mt-1">
104
- Generate soal baru untuk melihat penggunaan token.
 
 
 
105
  </p>
106
  </div>
107
- ) : (
108
- <div className="overflow-x-auto">
109
- <table className="w-full text-sm">
110
- <thead>
111
- <tr className="border-b border-[var(--oat-border)] text-[var(--warm-charcoal)]">
112
- <th className="text-left py-2 px-3 font-medium">Waktu</th>
113
- <th className="text-left py-2 px-3 font-medium">Mode</th>
114
- <th className="text-left py-2 px-3 font-medium">Status</th>
115
- <th className="text-left py-2 px-3 font-medium">Ujian</th>
116
- <th className="text-left py-2 px-3 font-medium">Section</th>
117
- <th className="text-right py-2 px-3 font-medium">Soal</th>
118
- <th className="text-right py-2 px-3 font-medium">Token</th>
119
- </tr>
120
- </thead>
121
- <tbody>
122
- {jobs.map((job) => {
123
- const isFailed = job.status === "failed";
124
- const isCancelled = job.status === "cancelled";
125
- return (
126
- <tr
127
- key={job.id}
128
- className={`border-b border-[var(--oat-border)] last:border-0 hover:bg-[var(--warm-cream)] transition-colors ${
129
- isFailed || isCancelled ? "opacity-70" : ""
130
- }`}
131
- >
132
- <td className="py-2.5 px-3 text-[var(--clay-black)] whitespace-nowrap">
133
- {new Date(job.createdAt).toLocaleTimeString("id-ID", {
134
- hour: "2-digit",
135
- minute: "2-digit",
136
- })}
137
- </td>
138
- <td className="py-2.5 px-3">
139
- <span
140
- className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold ${
141
- job.mode === "agentic"
142
- ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
143
- : "bg-[var(--lavender-300)] text-[var(--lavender-800)]"
144
- }`}
145
- >
146
- {job.mode === "agentic" ? "Agentic" : "Quick"}
147
- </span>
148
- </td>
149
- <td className="py-2.5 px-3">
150
- {job.status === "completed" ? (
151
- <span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--matcha-800)]">
152
- <MaterialIcon name="check_circle" className="text-xs" />
153
- Selesai
154
- </span>
155
- ) : isFailed ? (
156
- <span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--pomegranate-400)]">
157
- <MaterialIcon name="error" className="text-xs" />
158
- Gagal
159
- </span>
160
- ) : isCancelled ? (
161
- <span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--warm-charcoal)]">
162
- <MaterialIcon name="cancel" className="text-xs" />
163
- Dibatalkan
164
- </span>
165
- ) : (
166
- <span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--warm-silver)]">
167
- <MaterialIcon name="hourglass_empty" className="text-xs" />
168
- {job.status}
169
  </span>
170
- )}
171
- </td>
172
- <td className="py-2.5 px-3 text-[var(--clay-black)]">{job.examTypeId}</td>
173
- <td className="py-2.5 px-3 text-[var(--clay-black)]">{job.sectionTypeId}</td>
174
- <td className="py-2.5 px-3 text-right text-[var(--clay-black)]">{job.questionCount}</td>
175
- <td className="py-2.5 px-3 text-right text-[var(--clay-black)] font-medium">
176
- {job.tokensUsed?.toLocaleString("id-ID") ?? "-"}
177
- </td>
178
- </tr>
179
- );
180
- })}
181
- </tbody>
182
- </table>
183
- </div>
184
- )}
185
- </div>
186
- </CardContent>
187
- </Card>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
188
  );
189
  }
190
 
@@ -192,20 +196,36 @@ function RouteComponent() {
192
  const { configs, isLoading, addConfig, updateConfig, removeConfig } =
193
  useApiKeys();
194
 
 
 
 
195
  const [editingId, setEditingId] = useState<string | null>(null);
196
  const [isAdding, setIsAdding] = useState(false);
197
  const [isSaving, setIsSaving] = useState(false);
198
 
199
- // Form state
200
- const [form, setForm] = useState<Omit<ApiKeyConfig, "id"> & { apiKey: string }>({
201
- ...defaultConfig(),
202
- apiKey: "",
203
- });
 
 
 
 
 
 
 
 
 
204
 
205
  const resetForm = () => {
206
  setForm({ ...defaultConfig(), apiKey: "" });
207
  };
208
 
 
 
 
 
209
  const startAdd = () => {
210
  resetForm();
211
  setIsAdding(true);
@@ -219,7 +239,7 @@ function RouteComponent() {
219
  baseUrl: config.baseUrl,
220
  modelName: config.modelName,
221
  maxTokens: config.maxTokens ?? 16384,
222
- apiKey: "", // kosong = tidak ubah
223
  });
224
  setEditingId(config.id);
225
  setIsAdding(false);
@@ -251,9 +271,9 @@ function RouteComponent() {
251
 
252
  const isFormOpen = isAdding || editingId !== null;
253
  const canSave =
254
- form.name.trim() &&
255
- form.baseUrl.trim() &&
256
- form.modelName.trim() &&
257
  (isAdding ? !!form.apiKey : true);
258
 
259
  return (
@@ -272,7 +292,7 @@ function RouteComponent() {
272
  </p>
273
  </section>
274
 
275
- <Tabs defaultValue="api-keys" className="w-full">
276
  <TabsList variant="line" className="mb-6">
277
  <TabsTrigger value="api-keys">API Keys</TabsTrigger>
278
  <TabsTrigger value="token-usage">Token Usage</TabsTrigger>
@@ -282,255 +302,33 @@ function RouteComponent() {
282
  <TabsContent value="api-keys" className="space-y-8">
283
  <div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
284
  <div className="lg:col-span-7 space-y-8">
285
- {/* API Key List */}
286
- <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
287
- <CardHeader>
288
- <div className="flex items-center justify-between">
289
- <div className="flex items-center gap-3">
290
- <MaterialIcon name="key" className="text-xl" />
291
- <div>
292
- <CardTitle className="font-headline text-[var(--clay-black)]">API Keys</CardTitle>
293
- <CardDescription className="text-[var(--warm-charcoal)]">
294
- {configs.length} konfigurasi tersimpan
295
- </CardDescription>
296
- </div>
297
- </div>
298
- {!isFormOpen && (
299
- <Button
300
- onClick={startAdd}
301
- className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] clay-shadow clay-hover text-sm"
302
- >
303
- <MaterialIcon name="add" className="mr-1" />
304
- Tambah
305
- </Button>
306
- )}
307
- </div>
308
- </CardHeader>
309
- <CardContent className="space-y-4">
310
- {isLoading ? (
311
- <div className="h-20 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-lg)]" />
312
- ) : configs.length === 0 ? (
313
- <div className="text-center py-8">
314
- <MaterialIcon name="key_off" className="text-4xl text-[var(--warm-silver)] mx-auto mb-3" />
315
- <p className="text-[var(--warm-charcoal)] font-semibold">Belum ada API key</p>
316
- <p className="text-xs text-[var(--warm-silver)] mt-1">
317
- Tambahkan minimal satu key untuk generate soal.
318
- </p>
319
- <Button
320
- onClick={startAdd}
321
- className="mt-4 bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)]"
322
- >
323
- <MaterialIcon name="add" className="mr-1" />
324
- Tambah API Key
325
- </Button>
326
- </div>
327
- ) : (
328
- <div className="space-y-3">
329
- {configs.map((config) => (
330
- <div
331
- key={config.id}
332
- className={`p-4 rounded-[var(--radius-lg)] border-2 transition-colors ${
333
- editingId === config.id
334
- ? "border-[var(--matcha-600)] bg-[var(--matcha-100)]"
335
- : "border-[var(--oat-border)] bg-[var(--warm-cream)]"
336
- }`}
337
- >
338
- <div className="flex items-center justify-between">
339
- <div className="min-w-0">
340
- <div className="flex items-center gap-2 mb-1">
341
- <span className="font-semibold text-[var(--clay-black)] truncate">
342
- {config.name}
343
- </span>
344
- <span className="px-2 py-0.5 rounded-full bg-[var(--matcha-300)] text-[var(--matcha-800)] text-[10px] font-semibold">
345
- {PROVIDERS.find((p) => p.value === config.provider)?.label ?? config.provider}
346
- </span>
347
- </div>
348
- <div className="text-xs text-[var(--warm-charcoal)]">
349
- {config.modelName} · {config.baseUrl.replace("https://", "").replace("/v1", "")}
350
- </div>
351
- </div>
352
- <div className="flex gap-2 shrink-0">
353
- <button
354
- onClick={() => startEdit(config)}
355
- className="p-2 rounded-[var(--radius-md)] hover:bg-[var(--oat-light)] text-[var(--warm-charcoal)] transition-colors"
356
- title="Edit"
357
- >
358
- <MaterialIcon name="edit" className="text-sm" />
359
- </button>
360
- <button
361
- onClick={() => {
362
- if (confirm("Yakin mau hapus konfigurasi ini?")) {
363
- removeConfig(config.id);
364
- }
365
- }}
366
- className="p-2 rounded-[var(--radius-md)] hover:bg-[var(--pomegranate-400)]/10 text-[var(--pomegranate-400)] transition-colors"
367
- title="Hapus"
368
- >
369
- <MaterialIcon name="delete" className="text-sm" />
370
- </button>
371
- </div>
372
- </div>
373
- </div>
374
- ))}
375
- </div>
376
- )}
377
- </CardContent>
378
- </Card>
379
 
380
- {/* Add/Edit Form */}
381
  {isFormOpen && (
382
- <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--matcha-400)] rounded-[var(--radius-xl)]">
383
- <CardHeader>
384
- <CardTitle className="font-headline text-[var(--clay-black)]">
385
- {isAdding ? "Tambah API Key Baru" : "Edit API Key"}
386
- </CardTitle>
387
- <CardDescription className="text-[var(--warm-charcoal)]">
388
- {isAdding
389
- ? "Tambahkan konfigurasi provider baru."
390
- : "Kosongkan field API Key jika tidak ingin mengubahnya."}
391
- </CardDescription>
392
- </CardHeader>
393
- <CardContent className="space-y-5">
394
- <div className="space-y-2">
395
- <Label htmlFor="name" className="text-[var(--clay-black)]">Nama Config</Label>
396
- <Input
397
- id="name"
398
- value={form.name}
399
- onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
400
- placeholder="Contoh: OpenAI GPT-4o"
401
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
402
- />
403
- </div>
404
-
405
- <div className="grid grid-cols-2 gap-4">
406
- <div className="space-y-2">
407
- <Label htmlFor="provider" className="text-[var(--clay-black)]">Provider</Label>
408
- <Select
409
- items={PROVIDERS}
410
- value={form.provider}
411
- onValueChange={(v: string | null) =>
412
- setForm((f) => ({ ...f, provider: v ?? "openai" }))
413
- }
414
- >
415
- <SelectTrigger id="provider">
416
- <SelectValue placeholder="Pilih provider" />
417
- </SelectTrigger>
418
- <SelectContent>
419
- <SelectGroup>
420
- {PROVIDERS.map((p) => (
421
- <SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
422
- ))}
423
- </SelectGroup>
424
- </SelectContent>
425
- </Select>
426
- </div>
427
- <div className="space-y-2">
428
- <Label htmlFor="model" className="text-[var(--clay-black)]">Model</Label>
429
- <Input
430
- id="model"
431
- value={form.modelName}
432
- onChange={(e) => setForm((f) => ({ ...f, modelName: e.target.value }))}
433
- placeholder="gpt-4o-mini"
434
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
435
- />
436
- </div>
437
- </div>
438
-
439
- <div className="grid grid-cols-2 gap-4">
440
- <div className="space-y-2">
441
- <Label htmlFor="baseUrl" className="text-[var(--clay-black)]">Base URL</Label>
442
- <Input
443
- id="baseUrl"
444
- value={form.baseUrl}
445
- onChange={(e) => setForm((f) => ({ ...f, baseUrl: e.target.value }))}
446
- placeholder="https://api.openai.com/v1"
447
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
448
- />
449
- </div>
450
- <div className="space-y-2">
451
- <Label htmlFor="maxTokens" className="text-[var(--clay-black)]">Max Tokens</Label>
452
- <Input
453
- id="maxTokens"
454
- type="number"
455
- min={1}
456
- max={1_000_000}
457
- value={form.maxTokens}
458
- onChange={(e) => setForm((f) => ({ ...f, maxTokens: Number(e.target.value) }))}
459
- placeholder="16384"
460
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
461
- />
462
- </div>
463
- </div>
464
-
465
- <div className="space-y-2">
466
- <Label htmlFor="apiKey" className="text-[var(--clay-black)]">
467
- API Key
468
- {!isAdding && (
469
- <span className="text-[var(--warm-silver)] font-normal ml-1">
470
- (kosongkan jika tidak diubah)
471
- </span>
472
- )}
473
- </Label>
474
- <Input
475
- id="apiKey"
476
- type="password"
477
- value={form.apiKey}
478
- onChange={(e) => setForm((f) => ({ ...f, apiKey: e.target.value }))}
479
- placeholder={isAdding ? "sk-..." : "•••••••• (kosongkan untuk tidak mengubah)"}
480
- className="rounded-[var(--radius-lg)] border-2 border-[var(--oat-border)] bg-[var(--pure-white)] text-[var(--clay-black)]"
481
- />
482
- </div>
483
-
484
- <div className="flex gap-3">
485
- <Button
486
- onClick={handleSave}
487
- disabled={!canSave || isSaving}
488
- className="bg-[var(--clay-black)] text-[var(--pure-white)] hover:bg-[var(--warm-charcoal)] rounded-[var(--radius-lg)] h-11 clay-shadow clay-hover"
489
- >
490
- {isSaving ? "Menyimpan..." : isAdding ? "Simpan" : "Update"}
491
- </Button>
492
- <Button
493
- variant="outline"
494
- onClick={cancelEdit}
495
- className="rounded-[var(--radius-lg)] h-11 border-2 border-[var(--oat-border)]"
496
- >
497
- Batal
498
- </Button>
499
- </div>
500
- </CardContent>
501
- </Card>
502
  )}
503
  </div>
504
 
505
  <div className="lg:col-span-5">
506
- <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)] sticky top-8">
507
- <CardHeader>
508
- <CardTitle className="font-headline text-[var(--clay-black)]">Tips</CardTitle>
509
- <CardDescription className="text-[var(--warm-charcoal)]">
510
- Panduan singkat mengelola API key.
511
- </CardDescription>
512
- </CardHeader>
513
- <CardContent className="space-y-4">
514
- <div className="text-xs text-[var(--warm-charcoal)] space-y-2">
515
- <p className="flex items-start gap-2">
516
- <MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
517
- BYOK — bawa key milik Anda sendiri
518
- </p>
519
- <p className="flex items-start gap-2">
520
- <MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
521
- Platform tidak menyimpan key di server
522
- </p>
523
- <p className="flex items-start gap-2">
524
- <MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
525
- Key dienkripsi secara lokal di browser
526
- </p>
527
- <p className="flex items-start gap-2">
528
- <MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
529
- Anda bisa menambahkan beberapa config
530
- </p>
531
- </div>
532
- </CardContent>
533
- </Card>
534
  </div>
535
  </div>
536
  </TabsContent>
@@ -540,45 +338,7 @@ function RouteComponent() {
540
  </TabsContent>
541
 
542
  <TabsContent value="security">
543
- <div className="max-w-xl">
544
- <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
545
- <CardHeader>
546
- <CardTitle className="font-headline text-[var(--clay-black)]">Keamanan</CardTitle>
547
- <CardDescription className="text-[var(--warm-charcoal)]">
548
- Cara penyimpanan API key Anda.
549
- </CardDescription>
550
- </CardHeader>
551
- <CardContent className="space-y-4">
552
- <div className="flex items-center gap-3 p-4 bg-[var(--matcha-300)] rounded-[var(--radius-lg)]">
553
- <MaterialIcon name="lock" className="text-xl text-[var(--matcha-800)]" />
554
- <div>
555
- <p className="font-medium text-[var(--matcha-800)]">Enkripsi Lokal</p>
556
- <p className="text-sm text-[var(--matcha-800)]/70">
557
- AES-GCM-256 dengan key di IndexedDB
558
- </p>
559
- </div>
560
- </div>
561
- <div className="text-xs text-[var(--warm-charcoal)] space-y-2">
562
- <p className="flex items-center gap-2">
563
- <MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
564
- API key tidak pernah dikirim ke server kami
565
- </p>
566
- <p className="flex items-center gap-2">
567
- <MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
568
- Data terenkripsi di localStorage browser Anda
569
- </p>
570
- <p className="flex items-center gap-2">
571
- <MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
572
- Key enkripsi tersimpan aman di IndexedDB
573
- </p>
574
- <p className="flex items-center gap-2">
575
- <MaterialIcon name="check_circle" className="text-xs text-[var(--matcha-600)] shrink-0 mt-0.5" />
576
- BYOK — Bring Your Own Key, platform tidak menyimpan key
577
- </p>
578
- </div>
579
- </CardContent>
580
- </Card>
581
- </div>
582
  </TabsContent>
583
  </Tabs>
584
  </div>
 
1
+ import { useState, startTransition } from "react";
2
  import { createFileRoute, redirect, Link } from "@tanstack/react-router";
3
  import { useQuery } from "@tanstack/react-query";
4
  import { authClient } from "@/lib/auth-client";
5
  import { useApiKeys, type ApiKeyConfig } from "@/hooks/use-api-key";
6
  import { trpc } from "@/utils/trpc";
 
 
 
 
7
  import {
8
  Tabs,
9
  TabsList,
10
  TabsTrigger,
11
  TabsContent,
12
  } from "@labas/ui/components/tabs";
13
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@labas/ui/components/card";
 
 
 
 
 
 
 
14
  import { MaterialIcon } from "@/components/ui/MaterialIcon";
15
+ import { TokenUsageChart } from "@/components/settings/TokenUsageChart";
16
+ import { ApiKeyList } from "@/components/settings/ApiKeyList";
17
+ import { ApiKeyForm } from "@/components/settings/ApiKeyForm";
18
+ import { TipsCard } from "@/components/settings/TipsCard";
19
+ import { SecurityInfo } from "@/components/settings/SecurityInfo";
20
+ import { z } from "zod";
21
 
22
  export const Route = createFileRoute("/settings")({
23
  component: RouteComponent,
24
+ validateSearch: z.object({
25
+ tab: z.enum(["api-keys", "token-usage", "security"]).optional(),
26
+ }).parse,
27
  beforeLoad: async () => {
28
  const session = await authClient.getSession();
29
  if (!session.data) {
 
52
  };
53
  }
54
 
55
+ type Tab = "api-keys" | "token-usage" | "security";
56
+
57
  function TokenUsageSection() {
58
  const { data, isLoading } = useQuery(trpc.ai.tokenUsageToday.queryOptions());
59
 
 
61
  const jobs = data?.jobs ?? [];
62
 
63
  return (
64
+ <div className="space-y-6">
65
+ <TokenUsageChart />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
+ <Card className="clay-shadow bg-[var(--pure-white)] border-2 border-[var(--oat-border)] rounded-[var(--radius-xl)]">
68
+ <CardHeader>
69
+ <div className="flex items-center gap-3">
70
+ <MaterialIcon name="toll" className="text-xl" />
71
+ <div>
72
+ <CardTitle className="font-headline text-[var(--clay-black)]">
73
+ Penggunaan Token Hari Ini
74
+ </CardTitle>
75
+ <CardDescription className="text-[var(--warm-charcoal)]">
76
+ Total token yang terpakai untuk generate soal hari ini.
77
+ </CardDescription>
78
  </div>
79
+ </div>
80
+ </CardHeader>
81
+ <CardContent className="space-y-6">
82
+ <div className="flex items-center gap-4 p-4 bg-[var(--matcha-300)] rounded-[var(--radius-lg)]">
83
+ <MaterialIcon name="toll" className="text-3xl text-[var(--matcha-800)]" />
84
+ <div>
85
+ <p className="text-sm text-[var(--matcha-800)]/80">Total Token Terpakai</p>
86
+ <p className="text-3xl font-extrabold text-[var(--matcha-800)]">
87
+ {isLoading ? "..." : totalTokens.toLocaleString("id-ID")}
88
  </p>
89
  </div>
90
+ </div>
91
+
92
+ <div>
93
+ <h3 className="text-sm font-semibold text-[var(--clay-black)] mb-3">
94
+ Riwayat Generate Hari Ini
95
+ </h3>
96
+ {isLoading ? (
97
+ <div className="space-y-2">
98
+ {[1, 2, 3].map((i) => (
99
+ <div key={i} className="h-12 bg-[var(--oat-light)] animate-pulse rounded-[var(--radius-lg)]" />
100
+ ))}
101
+ </div>
102
+ ) : jobs.length === 0 ? (
103
+ <div className="text-center py-8 border-2 border-dashed border-[var(--oat-border)] rounded-[var(--radius-lg)]">
104
+ <MaterialIcon name="receipt_long" className="text-4xl text-[var(--warm-silver)] mx-auto mb-3" />
105
+ <p className="text-[var(--warm-charcoal)] font-semibold">Belum ada generate hari ini</p>
106
+ <p className="text-xs text-[var(--warm-silver)] mt-1">
107
+ Generate soal baru untuk melihat penggunaan token.
108
+ </p>
109
+ </div>
110
+ ) : (
111
+ <div className="overflow-x-auto">
112
+ <table className="w-full text-sm">
113
+ <thead>
114
+ <tr className="border-b border-[var(--oat-border)] text-[var(--warm-charcoal)]">
115
+ <th className="text-left py-2 px-3 font-medium">Waktu</th>
116
+ <th className="text-left py-2 px-3 font-medium">Mode</th>
117
+ <th className="text-left py-2 px-3 font-medium">Status</th>
118
+ <th className="text-left py-2 px-3 font-medium">Ujian</th>
119
+ <th className="text-left py-2 px-3 font-medium">Section</th>
120
+ <th className="text-right py-2 px-3 font-medium">Soal</th>
121
+ <th className="text-right py-2 px-3 font-medium">Token</th>
122
+ </tr>
123
+ </thead>
124
+ <tbody>
125
+ {jobs.map((job) => {
126
+ const isFailed = job.status === "failed";
127
+ const isCancelled = job.status === "cancelled";
128
+ return (
129
+ <tr
130
+ key={job.id}
131
+ className={`border-b border-[var(--oat-border)] last:border-0 hover:bg-[var(--warm-cream)] transition-colors ${
132
+ isFailed || isCancelled ? "opacity-70" : ""
133
+ }`}
134
+ >
135
+ <td className="py-2.5 px-3 text-[var(--clay-black)] whitespace-nowrap">
136
+ {new Date(job.createdAt).toLocaleTimeString("id-ID", {
137
+ hour: "2-digit",
138
+ minute: "2-digit",
139
+ })}
140
+ </td>
141
+ <td className="py-2.5 px-3">
142
+ <span
143
+ className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold ${
144
+ job.mode === "agentic"
145
+ ? "bg-[var(--matcha-300)] text-[var(--matcha-800)]"
146
+ : "bg-[var(--lavender-300)] text-[var(--lavender-800)]"
147
+ }`}
148
+ >
149
+ {job.mode === "agentic" ? "Agentic" : "Quick"}
 
 
150
  </span>
151
+ </td>
152
+ <td className="py-2.5 px-3">
153
+ {job.status === "completed" ? (
154
+ <span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--matcha-800)]">
155
+ <MaterialIcon name="check_circle" className="text-xs" />
156
+ Selesai
157
+ </span>
158
+ ) : isFailed ? (
159
+ <span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--pomegranate-400)]">
160
+ <MaterialIcon name="error" className="text-xs" />
161
+ Gagal
162
+ </span>
163
+ ) : isCancelled ? (
164
+ <span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--warm-charcoal)]">
165
+ <MaterialIcon name="cancel" className="text-xs" />
166
+ Dibatalkan
167
+ </span>
168
+ ) : (
169
+ <span className="inline-flex items-center gap-1 text-[10px] font-semibold text-[var(--warm-silver)]">
170
+ <MaterialIcon name="hourglass_empty" className="text-xs" />
171
+ {job.status}
172
+ </span>
173
+ )}
174
+ </td>
175
+ <td className="py-2.5 px-3 text-[var(--clay-black)]">{job.examTypeId}</td>
176
+ <td className="py-2.5 px-3 text-[var(--clay-black)]">{job.sectionTypeId}</td>
177
+ <td className="py-2.5 px-3 text-right text-[var(--clay-black)]">{job.questionCount}</td>
178
+ <td className="py-2.5 px-3 text-right text-[var(--clay-black)] font-medium">
179
+ {job.tokensUsed?.toLocaleString("id-ID") ?? "-"}
180
+ </td>
181
+ </tr>
182
+ );
183
+ })}
184
+ </tbody>
185
+ </table>
186
+ </div>
187
+ )}
188
+ </div>
189
+ </CardContent>
190
+ </Card>
191
+ </div>
192
  );
193
  }
194
 
 
196
  const { configs, isLoading, addConfig, updateConfig, removeConfig } =
197
  useApiKeys();
198
 
199
+ const search = Route.useSearch();
200
+ const navigate = Route.useNavigate();
201
+
202
  const [editingId, setEditingId] = useState<string | null>(null);
203
  const [isAdding, setIsAdding] = useState(false);
204
  const [isSaving, setIsSaving] = useState(false);
205
 
206
+ const activeTab: Tab = search.tab ?? "api-keys";
207
+
208
+ const setTab = (tab: string) => {
209
+ startTransition(() => {
210
+ navigate({ search: { tab: tab as Tab } });
211
+ });
212
+ };
213
+
214
+ const [form, setForm] = useState<Omit<ApiKeyConfig, "id"> & { apiKey: string }>(
215
+ () => ({
216
+ ...defaultConfig(),
217
+ apiKey: "",
218
+ }),
219
+ );
220
 
221
  const resetForm = () => {
222
  setForm({ ...defaultConfig(), apiKey: "" });
223
  };
224
 
225
+ const handleFormChange = (field: keyof typeof form, value: string | number) => {
226
+ setForm((prev) => ({ ...prev, [field]: value }));
227
+ };
228
+
229
  const startAdd = () => {
230
  resetForm();
231
  setIsAdding(true);
 
239
  baseUrl: config.baseUrl,
240
  modelName: config.modelName,
241
  maxTokens: config.maxTokens ?? 16384,
242
+ apiKey: "",
243
  });
244
  setEditingId(config.id);
245
  setIsAdding(false);
 
271
 
272
  const isFormOpen = isAdding || editingId !== null;
273
  const canSave =
274
+ !!form.name.trim() &&
275
+ !!form.baseUrl.trim() &&
276
+ !!form.modelName.trim() &&
277
  (isAdding ? !!form.apiKey : true);
278
 
279
  return (
 
292
  </p>
293
  </section>
294
 
295
+ <Tabs value={activeTab} onValueChange={setTab} className="w-full">
296
  <TabsList variant="line" className="mb-6">
297
  <TabsTrigger value="api-keys">API Keys</TabsTrigger>
298
  <TabsTrigger value="token-usage">Token Usage</TabsTrigger>
 
302
  <TabsContent value="api-keys" className="space-y-8">
303
  <div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
304
  <div className="lg:col-span-7 space-y-8">
305
+ <ApiKeyList
306
+ configs={configs}
307
+ isLoading={isLoading}
308
+ editingId={editingId}
309
+ isFormOpen={isFormOpen}
310
+ providers={PROVIDERS}
311
+ onStartAdd={startAdd}
312
+ onStartEdit={startEdit}
313
+ onRemove={removeConfig}
314
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
 
 
316
  {isFormOpen && (
317
+ <ApiKeyForm
318
+ isAdding={isAdding}
319
+ isSaving={isSaving}
320
+ form={form}
321
+ canSave={canSave}
322
+ providers={PROVIDERS}
323
+ onChange={handleFormChange}
324
+ onSave={handleSave}
325
+ onCancel={cancelEdit}
326
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  )}
328
  </div>
329
 
330
  <div className="lg:col-span-5">
331
+ <TipsCard />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  </div>
333
  </div>
334
  </TabsContent>
 
338
  </TabsContent>
339
 
340
  <TabsContent value="security">
341
+ <SecurityInfo />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  </TabsContent>
343
  </Tabs>
344
  </div>
packages/api/src/routers/ai.ts CHANGED
@@ -129,6 +129,109 @@ export const aiRouter = router({
129
  };
130
  }),
131
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  retryJob: protectedProcedure
133
  .input(z.object({ jobId: z.string().uuid() }))
134
  .mutation(async ({ ctx, input }) => {
 
129
  };
130
  }),
131
 
132
+ tokenUsageHistory: protectedProcedure
133
+ .input(
134
+ z.object({
135
+ period: z.enum(["daily", "weekly", "monthly"]),
136
+ }),
137
+ )
138
+ .query(async ({ ctx, input }) => {
139
+ const userId = ctx.session.user.id;
140
+ const now = new Date();
141
+
142
+ let startDate: Date;
143
+ let dateFormat: (d: Date) => string;
144
+
145
+ switch (input.period) {
146
+ case "daily": {
147
+ startDate = new Date(now);
148
+ startDate.setDate(startDate.getDate() - 6);
149
+ startDate.setHours(0, 0, 0, 0);
150
+ dateFormat = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
151
+ break;
152
+ }
153
+ case "weekly": {
154
+ startDate = new Date(now);
155
+ startDate.setDate(startDate.getDate() - 27);
156
+ startDate.setHours(0, 0, 0, 0);
157
+ dateFormat = (d: Date) => {
158
+ const weekStart = new Date(d);
159
+ weekStart.setDate(weekStart.getDate() - weekStart.getDay());
160
+ return `${weekStart.getFullYear()}-W${String(Math.ceil(((weekStart.getTime() - new Date(weekStart.getFullYear(), 0, 1).getTime()) / 86400000 + 1) / 7)).padStart(2, "0")}`;
161
+ };
162
+ break;
163
+ }
164
+ case "monthly": {
165
+ startDate = new Date(now);
166
+ startDate.setMonth(startDate.getMonth() - 5);
167
+ startDate.setDate(1);
168
+ startDate.setHours(0, 0, 0, 0);
169
+ dateFormat = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
170
+ break;
171
+ }
172
+ }
173
+
174
+ const rows = await db
175
+ .select({
176
+ date: sql<string>`date_trunc('day', ${generationJob.createdAt})::date`,
177
+ totalTokens: sql<number>`coalesce(sum(${generationJob.tokensUsed}), 0)`,
178
+ jobCount: sql<number>`count(*)`,
179
+ })
180
+ .from(generationJob)
181
+ .where(
182
+ and(
183
+ eq(generationJob.userId, userId),
184
+ gte(generationJob.createdAt, startDate),
185
+ ),
186
+ )
187
+ .groupBy(sql`date_trunc('day', ${generationJob.createdAt})::date`)
188
+ .orderBy(sql`date_trunc('day', ${generationJob.createdAt})::date`);
189
+
190
+ const grouped: Record<string, { date: string; totalTokens: number; jobCount: number }> = {};
191
+
192
+ for (const row of rows) {
193
+ const d = new Date(row.date as string);
194
+ const key = dateFormat(d);
195
+ if (!grouped[key]) {
196
+ grouped[key] = { date: key, totalTokens: 0, jobCount: 0 };
197
+ }
198
+ grouped[key].totalTokens += Number(row.totalTokens);
199
+ grouped[key].jobCount += Number(row.jobCount);
200
+ }
201
+
202
+ const labels: { date: string; label: string }[] = [];
203
+ if (input.period === "daily") {
204
+ for (let i = 6; i >= 0; i--) {
205
+ const d = new Date(now);
206
+ d.setDate(d.getDate() - i);
207
+ labels.push({ date: dateFormat(d), label: `${d.getDate()}/${d.getMonth() + 1}` });
208
+ }
209
+ } else if (input.period === "weekly") {
210
+ for (let i = 3; i >= 0; i--) {
211
+ const d = new Date(now);
212
+ d.setDate(d.getDate() - i * 7);
213
+ const weekStart = new Date(d);
214
+ weekStart.setDate(weekStart.getDate() - weekStart.getDay());
215
+ labels.push({ date: dateFormat(weekStart), label: `W${i === 0 ? "Ini" : `${i}`}` });
216
+ }
217
+ } else {
218
+ for (let i = 5; i >= 0; i--) {
219
+ const d = new Date(now);
220
+ d.setMonth(d.getMonth() - i);
221
+ const monthNames = ["Jan", "Feb", "Mar", "Apr", "Mei", "Jun", "Jul", "Agu", "Sep", "Okt", "Nov", "Des"];
222
+ labels.push({ date: dateFormat(d), label: `${monthNames[d.getMonth()]} ${d.getFullYear()}` });
223
+ }
224
+ }
225
+
226
+ const result = labels.map((l) => ({
227
+ ...l,
228
+ totalTokens: grouped[l.date]?.totalTokens ?? 0,
229
+ jobCount: grouped[l.date]?.jobCount ?? 0,
230
+ }));
231
+
232
+ return result;
233
+ }),
234
+
235
  retryJob: protectedProcedure
236
  .input(z.object({ jobId: z.string().uuid() }))
237
  .mutation(async ({ ctx, input }) => {