Codex commited on
Commit
cf677dd
·
1 Parent(s): 3931350

feat: 优化前端主题和图片聊天滚动体验

Browse files
web/src/app/globals.css CHANGED
@@ -122,6 +122,11 @@
122
  overscroll-behavior: none;
123
  }
124
 
 
 
 
 
 
125
  body {
126
  @apply text-foreground;
127
  }
@@ -138,6 +143,83 @@
138
  }
139
  }
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  @keyframes grow {
142
  from { transform: scaleX(0); }
143
  to { transform: scaleX(1); }
 
122
  overscroll-behavior: none;
123
  }
124
 
125
+ .dark,
126
+ .dark body {
127
+ background: radial-gradient(circle at top left, rgba(55, 48, 43, 0.72), rgba(28, 25, 23, 0.98) 40%, rgba(12, 10, 9, 1) 100%);
128
+ }
129
+
130
  body {
131
  @apply text-foreground;
132
  }
 
143
  }
144
  }
145
 
146
+ .dark .bg-white,
147
+ .dark .bg-white\/85,
148
+ .dark .bg-white\/90,
149
+ .dark .bg-white\/95,
150
+ .dark .hover\:bg-white:hover {
151
+ background-color: rgb(28 25 23 / 0.94) !important;
152
+ }
153
+
154
+ .dark .bg-white\/80,
155
+ .dark .bg-white\/70,
156
+ .dark .bg-white\/60 {
157
+ background-color: rgb(41 37 36 / 0.78) !important;
158
+ }
159
+
160
+ .dark .bg-stone-50,
161
+ .dark .bg-stone-50\/70,
162
+ .dark .hover\:bg-stone-50\/70:hover,
163
+ .dark .bg-stone-100,
164
+ .dark .hover\:bg-stone-100:hover,
165
+ .dark .hover\:bg-stone-200:hover {
166
+ background-color: rgb(41 37 36 / 0.86) !important;
167
+ }
168
+
169
+ .dark .bg-stone-200 {
170
+ background-color: rgb(68 64 60 / 0.9) !important;
171
+ }
172
+
173
+ .dark .border-white,
174
+ .dark .border-white\/80,
175
+ .dark .border-white\/70,
176
+ .dark .border-stone-100,
177
+ .dark .border-stone-100\/80,
178
+ .dark .border-stone-100\/50,
179
+ .dark .border-stone-200,
180
+ .dark .border-stone-200\/70,
181
+ .dark .border-stone-300,
182
+ .dark .focus-visible\:border-stone-300:focus-visible {
183
+ border-color: rgb(255 255 255 / 0.11) !important;
184
+ }
185
+
186
+ .dark .text-stone-950,
187
+ .dark .text-stone-900,
188
+ .dark .text-stone-800 {
189
+ color: rgb(250 250 249) !important;
190
+ }
191
+
192
+ .dark .text-stone-700,
193
+ .dark .text-stone-600 {
194
+ color: rgb(214 211 209) !important;
195
+ }
196
+
197
+ .dark .text-stone-500,
198
+ .dark .text-stone-400 {
199
+ color: rgb(168 162 158) !important;
200
+ }
201
+
202
+ .dark .text-stone-300 {
203
+ color: rgb(214 211 209) !important;
204
+ }
205
+
206
+ .dark .placeholder\:text-stone-400::placeholder,
207
+ .dark .placeholder\:text-muted-foreground::placeholder {
208
+ color: rgb(120 113 108) !important;
209
+ }
210
+
211
+ .dark .hover\:text-stone-700:hover,
212
+ .dark .hover\:text-stone-800:hover,
213
+ .dark .hover\:text-stone-900:hover {
214
+ color: rgb(250 250 249) !important;
215
+ }
216
+
217
+ .dark .shadow-sm,
218
+ .dark .shadow-xs,
219
+ .dark [class*="shadow-"] {
220
+ box-shadow: none !important;
221
+ }
222
+
223
  @keyframes grow {
224
  from { transform: scaleX(0); }
225
  to { transform: scaleX(1); }
web/src/app/image/components/image-composer.tsx CHANGED
@@ -137,7 +137,7 @@ export function ImageComposer({
137
  </div>
138
  ) : null}
139
 
140
- <div className="overflow-hidden rounded-[24px] border border-stone-200 bg-white shadow-[0_14px_60px_-42px_rgba(15,23,42,0.45)] sm:rounded-[32px] sm:shadow-none">
141
  <div
142
  className="relative cursor-text"
143
  onClick={() => {
@@ -167,10 +167,10 @@ export function ImageComposer({
167
  void onSubmit();
168
  }
169
  }}
170
- className="min-h-[82px] resize-none rounded-[24px] border-0 bg-transparent px-4 pt-4 pb-2 text-[15px] leading-6 text-stone-900 shadow-none placeholder:text-stone-400 focus-visible:ring-0 sm:min-h-[148px] sm:rounded-[32px] sm:px-6 sm:pt-6 sm:pb-20 sm:leading-7"
171
  />
172
 
173
- <div className="rounded-b-[24px] border-t border-stone-100 bg-white px-3 pb-3 pt-2 sm:absolute sm:inset-x-0 sm:bottom-0 sm:rounded-b-none sm:border-t-0 sm:bg-gradient-to-t sm:from-white sm:via-white/95 sm:to-transparent sm:px-6 sm:pb-4 sm:pt-6" onClick={(event) => event.stopPropagation()}>
174
  <div className="flex items-end justify-between gap-2 sm:gap-3">
175
  <div className="hide-scrollbar flex min-w-0 flex-1 flex-nowrap items-center gap-1.5 overflow-x-auto pb-0.5 sm:flex-wrap sm:gap-3 sm:overflow-visible sm:pb-0">
176
  <Button
@@ -279,4 +279,3 @@ export function ImageComposer({
279
  </div>
280
  );
281
  }
282
-
 
137
  </div>
138
  ) : null}
139
 
140
+ <div className="overflow-hidden rounded-[24px] border border-stone-200 bg-white shadow-[0_14px_60px_-42px_rgba(15,23,42,0.45)] dark:border-white/10 dark:bg-stone-950/80 sm:rounded-[32px] sm:shadow-none">
141
  <div
142
  className="relative cursor-text"
143
  onClick={() => {
 
167
  void onSubmit();
168
  }
169
  }}
170
+ className="min-h-[82px] resize-none rounded-[24px] border-0 bg-transparent px-4 pt-4 pb-2 text-[15px] leading-6 text-stone-900 shadow-none placeholder:text-stone-400 focus-visible:ring-0 dark:text-stone-100 dark:placeholder:text-stone-500 sm:min-h-[148px] sm:rounded-[32px] sm:px-6 sm:pt-6 sm:pb-20 sm:leading-7"
171
  />
172
 
173
+ <div className="rounded-b-[24px] border-t border-stone-100 bg-white px-3 pb-3 pt-2 dark:border-white/10 dark:bg-stone-950/95 sm:absolute sm:inset-x-0 sm:bottom-0 sm:rounded-b-none sm:border-t-0 sm:bg-gradient-to-t sm:from-white sm:via-white/95 sm:to-transparent sm:px-6 sm:pb-4 sm:pt-6 sm:dark:from-stone-950 sm:dark:via-stone-950/95 sm:dark:to-stone-950/0" onClick={(event) => event.stopPropagation()}>
174
  <div className="flex items-end justify-between gap-2 sm:gap-3">
175
  <div className="hide-scrollbar flex min-w-0 flex-1 flex-nowrap items-center gap-1.5 overflow-x-auto pb-0.5 sm:flex-wrap sm:gap-3 sm:overflow-visible sm:pb-0">
176
  <Button
 
279
  </div>
280
  );
281
  }
 
web/src/app/image/page.tsx CHANGED
@@ -1,7 +1,7 @@
1
  "use client";
2
 
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
- import { History, LoaderCircle, Plus, Trash2 } from "lucide-react";
5
  import { toast } from "sonner";
6
 
7
  import { ImageComposer } from "@/app/image/components/image-composer";
@@ -45,12 +45,17 @@ import {
45
  const ACTIVE_CONVERSATION_STORAGE_KEY = "chatgpt2api:image_active_conversation_id";
46
  const IMAGE_SIZE_STORAGE_KEY = "chatgpt2api:image_last_size";
47
  const IMAGE_COUNT_STORAGE_KEY = "chatgpt2api:image_last_count";
 
48
 
49
  function clampImageCount(value: string) {
50
  return String(Math.min(100, Math.max(1, Math.floor(Number(value) || 1))));
51
  }
52
  const activeConversationQueueIds = new Set<string>();
53
 
 
 
 
 
54
  function buildConversationTitle(prompt: string) {
55
  const trimmed = prompt.trim();
56
  if (trimmed.length <= 12) {
@@ -341,6 +346,9 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
341
  const didLoadQuotaRef = useRef(false);
342
  const conversationsRef = useRef<ImageConversation[]>([]);
343
  const resultsViewportRef = useRef<HTMLDivElement>(null);
 
 
 
344
  const textareaRef = useRef<HTMLTextAreaElement>(null);
345
  const fileInputRef = useRef<HTMLInputElement>(null);
346
 
@@ -357,6 +365,7 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
357
  const [lightboxImages, setLightboxImages] = useState<ImageLightboxItem[]>([]);
358
  const [lightboxOpen, setLightboxOpen] = useState(false);
359
  const [lightboxIndex, setLightboxIndex] = useState(0);
 
360
  const [deleteConfirm, setDeleteConfirm] = useState<
361
  | { type: "one"; id: string }
362
  | { type: "prompt"; conversationId: string; turnId: string }
@@ -403,6 +412,46 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
403
  conversationsRef.current = conversations;
404
  }, [conversations]);
405
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
406
  useEffect(() => {
407
  let cancelled = false;
408
 
@@ -476,14 +525,36 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
476
 
477
  useEffect(() => {
478
  if (!selectedConversation) {
 
 
 
479
  return;
480
  }
481
 
482
- resultsViewportRef.current?.scrollTo({
483
- top: resultsViewportRef.current.scrollHeight,
484
- behavior: "smooth",
485
- });
486
- }, [selectedConversation?.updatedAt, selectedConversation?.turns.length, selectedConversation]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487
 
488
  useEffect(() => {
489
  if (typeof window === "undefined") {
@@ -566,6 +637,8 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
566
  }, [clearComposerInputs]);
567
 
568
  const handleCreateDraft = () => {
 
 
569
  setSelectedConversationId(null);
570
  resetComposer();
571
  textareaRef.current?.focus();
@@ -1110,8 +1183,10 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
1110
  createdAt: now,
1111
  updatedAt: now,
1112
  turns: [draftTurn],
1113
- };
1114
 
 
 
1115
  setSelectedConversationId(conversationId);
1116
  clearComposerInputs();
1117
 
@@ -1203,21 +1278,36 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
1203
  </Button>
1204
  </div>
1205
 
1206
- <div
1207
- ref={resultsViewportRef}
1208
- className="hide-scrollbar min-h-0 flex-1 overscroll-contain overflow-y-auto px-1 py-2 sm:px-4 sm:py-4"
1209
- >
1210
- <ImageResults
1211
- selectedConversation={selectedConversation}
1212
- onOpenLightbox={openLightbox}
1213
- onContinueEdit={handleContinueEdit}
1214
- onDeletePrompt={openDeletePromptConfirm}
1215
- onDeleteResults={openDeleteResultsConfirm}
1216
- onReuseTurnConfig={handleReuseTurnConfig}
1217
- onRegenerateTurn={handleRegenerateTurn}
1218
- onRetryImage={handleRetryImage}
1219
- formatConversationTime={formatConversationTime}
1220
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1221
  </div>
1222
 
1223
  <ImageComposer
 
1
  "use client";
2
 
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ import { ArrowDown, History, LoaderCircle, Plus, Trash2 } from "lucide-react";
5
  import { toast } from "sonner";
6
 
7
  import { ImageComposer } from "@/app/image/components/image-composer";
 
45
  const ACTIVE_CONVERSATION_STORAGE_KEY = "chatgpt2api:image_active_conversation_id";
46
  const IMAGE_SIZE_STORAGE_KEY = "chatgpt2api:image_last_size";
47
  const IMAGE_COUNT_STORAGE_KEY = "chatgpt2api:image_last_count";
48
+ const SCROLL_TO_LATEST_THRESHOLD = 160;
49
 
50
  function clampImageCount(value: string) {
51
  return String(Math.min(100, Math.max(1, Math.floor(Number(value) || 1))));
52
  }
53
  const activeConversationQueueIds = new Set<string>();
54
 
55
+ function getResultsDistanceFromBottom(element: HTMLElement) {
56
+ return element.scrollHeight - element.scrollTop - element.clientHeight;
57
+ }
58
+
59
  function buildConversationTitle(prompt: string) {
60
  const trimmed = prompt.trim();
61
  if (trimmed.length <= 12) {
 
346
  const didLoadQuotaRef = useRef(false);
347
  const conversationsRef = useRef<ImageConversation[]>([]);
348
  const resultsViewportRef = useRef<HTMLDivElement>(null);
349
+ const lastConversationIdRef = useRef<string | null>(null);
350
+ const shouldStickToBottomRef = useRef(true);
351
+ const scrollRafRef = useRef<number | null>(null);
352
  const textareaRef = useRef<HTMLTextAreaElement>(null);
353
  const fileInputRef = useRef<HTMLInputElement>(null);
354
 
 
365
  const [lightboxImages, setLightboxImages] = useState<ImageLightboxItem[]>([]);
366
  const [lightboxOpen, setLightboxOpen] = useState(false);
367
  const [lightboxIndex, setLightboxIndex] = useState(0);
368
+ const [showScrollToLatest, setShowScrollToLatest] = useState(false);
369
  const [deleteConfirm, setDeleteConfirm] = useState<
370
  | { type: "one"; id: string }
371
  | { type: "prompt"; conversationId: string; turnId: string }
 
412
  conversationsRef.current = conversations;
413
  }, [conversations]);
414
 
415
+ const scrollResultsToLatest = useCallback((behavior: ScrollBehavior = "smooth") => {
416
+ const element = resultsViewportRef.current;
417
+ if (!element) {
418
+ return;
419
+ }
420
+
421
+ shouldStickToBottomRef.current = true;
422
+ setShowScrollToLatest(false);
423
+ element.scrollTo({
424
+ top: element.scrollHeight,
425
+ behavior,
426
+ });
427
+ }, []);
428
+
429
+ const handleResultsScroll = useCallback(() => {
430
+ if (scrollRafRef.current !== null) {
431
+ return;
432
+ }
433
+
434
+ scrollRafRef.current = window.requestAnimationFrame(() => {
435
+ scrollRafRef.current = null;
436
+ const element = resultsViewportRef.current;
437
+ if (!element) {
438
+ return;
439
+ }
440
+
441
+ const isAwayFromLatest = getResultsDistanceFromBottom(element) > SCROLL_TO_LATEST_THRESHOLD;
442
+ shouldStickToBottomRef.current = !isAwayFromLatest;
443
+ setShowScrollToLatest(isAwayFromLatest);
444
+ });
445
+ }, []);
446
+
447
+ useEffect(() => {
448
+ return () => {
449
+ if (scrollRafRef.current !== null) {
450
+ window.cancelAnimationFrame(scrollRafRef.current);
451
+ }
452
+ };
453
+ }, []);
454
+
455
  useEffect(() => {
456
  let cancelled = false;
457
 
 
525
 
526
  useEffect(() => {
527
  if (!selectedConversation) {
528
+ lastConversationIdRef.current = null;
529
+ shouldStickToBottomRef.current = true;
530
+ setShowScrollToLatest(false);
531
  return;
532
  }
533
 
534
+ const element = resultsViewportRef.current;
535
+ if (!element) {
536
+ return;
537
+ }
538
+
539
+ const didSwitchConversation = lastConversationIdRef.current !== selectedConversation.id;
540
+ lastConversationIdRef.current = selectedConversation.id;
541
+
542
+ if (didSwitchConversation) {
543
+ requestAnimationFrame(() => scrollResultsToLatest("auto"));
544
+ return;
545
+ }
546
+
547
+ const shouldFollowLatest =
548
+ shouldStickToBottomRef.current ||
549
+ getResultsDistanceFromBottom(element) <= SCROLL_TO_LATEST_THRESHOLD;
550
+
551
+ if (shouldFollowLatest) {
552
+ requestAnimationFrame(() => scrollResultsToLatest("smooth"));
553
+ return;
554
+ }
555
+
556
+ setShowScrollToLatest(true);
557
+ }, [selectedConversation?.id, selectedConversation?.updatedAt, selectedConversation?.turns.length, scrollResultsToLatest]);
558
 
559
  useEffect(() => {
560
  if (typeof window === "undefined") {
 
637
  }, [clearComposerInputs]);
638
 
639
  const handleCreateDraft = () => {
640
+ shouldStickToBottomRef.current = true;
641
+ setShowScrollToLatest(false);
642
  setSelectedConversationId(null);
643
  resetComposer();
644
  textareaRef.current?.focus();
 
1183
  createdAt: now,
1184
  updatedAt: now,
1185
  turns: [draftTurn],
1186
+ };
1187
 
1188
+ shouldStickToBottomRef.current = true;
1189
+ setShowScrollToLatest(false);
1190
  setSelectedConversationId(conversationId);
1191
  clearComposerInputs();
1192
 
 
1278
  </Button>
1279
  </div>
1280
 
1281
+ <div className="relative min-h-0 flex-1">
1282
+ <div
1283
+ ref={resultsViewportRef}
1284
+ onScroll={handleResultsScroll}
1285
+ className="hide-scrollbar h-full overscroll-contain scroll-smooth overflow-y-auto px-1 py-2 sm:px-4 sm:py-4"
1286
+ >
1287
+ <ImageResults
1288
+ selectedConversation={selectedConversation}
1289
+ onOpenLightbox={openLightbox}
1290
+ onContinueEdit={handleContinueEdit}
1291
+ onDeletePrompt={openDeletePromptConfirm}
1292
+ onDeleteResults={openDeleteResultsConfirm}
1293
+ onReuseTurnConfig={handleReuseTurnConfig}
1294
+ onRegenerateTurn={handleRegenerateTurn}
1295
+ onRetryImage={handleRetryImage}
1296
+ formatConversationTime={formatConversationTime}
1297
+ />
1298
+ </div>
1299
+
1300
+ {showScrollToLatest ? (
1301
+ <button
1302
+ type="button"
1303
+ aria-label="滚动到最新消息"
1304
+ title="滚动到最新消息"
1305
+ onClick={() => scrollResultsToLatest("smooth")}
1306
+ className="absolute bottom-4 left-1/2 z-20 inline-flex size-11 -translate-x-1/2 items-center justify-center rounded-full border border-stone-200 bg-white/95 text-stone-700 shadow-lg shadow-stone-200/60 backdrop-blur transition hover:-translate-y-0.5 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-stone-400 dark:border-white/10 dark:bg-stone-800/95 dark:text-stone-100 dark:shadow-black/40 dark:hover:bg-stone-700"
1307
+ >
1308
+ <ArrowDown className="size-5" />
1309
+ </button>
1310
+ ) : null}
1311
  </div>
1312
 
1313
  <ImageComposer
web/src/app/layout.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import type { Metadata, Viewport } from "next";
2
  import { Toaster } from "sonner";
3
  import "./globals.css";
 
4
  import { TopNav } from "@/components/top-nav";
5
 
6
  export const metadata: Metadata = {
@@ -13,7 +14,10 @@ export const viewport: Viewport = {
13
  initialScale: 1,
14
  maximumScale: 1,
15
  userScalable: false,
16
- themeColor: "#f0ebe3",
 
 
 
17
  };
18
 
19
  export default function RootLayout({
@@ -23,6 +27,9 @@ export default function RootLayout({
23
  }>) {
24
  return (
25
  <html lang="zh-CN" suppressHydrationWarning>
 
 
 
26
  <body
27
  className="antialiased"
28
  style={{
@@ -31,7 +38,7 @@ export default function RootLayout({
31
  }}
32
  >
33
  <Toaster position="top-center" richColors offset={48} />
34
- <main className="min-h-screen overflow-x-hidden bg-[radial-gradient(circle_at_top_left,_rgba(255,255,255,0.92),_rgba(245,239,231,0.96)_42%,_rgba(240,235,227,0.99)_100%)] px-4 pt-0 pb-2 text-stone-900 sm:px-6 sm:pt-2 lg:px-8">
35
  <div className="mx-auto box-border flex min-h-screen max-w-[1440px] flex-col gap-2 pt-[env(safe-area-inset-top)] sm:gap-5 sm:pt-0">
36
  <TopNav />
37
  {children}
 
1
  import type { Metadata, Viewport } from "next";
2
  import { Toaster } from "sonner";
3
  import "./globals.css";
4
+ import { ThemeScript } from "@/components/theme-script";
5
  import { TopNav } from "@/components/top-nav";
6
 
7
  export const metadata: Metadata = {
 
14
  initialScale: 1,
15
  maximumScale: 1,
16
  userScalable: false,
17
+ themeColor: [
18
+ { media: "(prefers-color-scheme: light)", color: "#f0ebe3" },
19
+ { media: "(prefers-color-scheme: dark)", color: "#12110f" },
20
+ ],
21
  };
22
 
23
  export default function RootLayout({
 
27
  }>) {
28
  return (
29
  <html lang="zh-CN" suppressHydrationWarning>
30
+ <head>
31
+ <ThemeScript />
32
+ </head>
33
  <body
34
  className="antialiased"
35
  style={{
 
38
  }}
39
  >
40
  <Toaster position="top-center" richColors offset={48} />
41
+ <main className="min-h-screen overflow-x-hidden bg-[radial-gradient(circle_at_top_left,_rgba(255,255,255,0.92),_rgba(245,239,231,0.96)_42%,_rgba(240,235,227,0.99)_100%)] px-4 pt-0 pb-2 text-stone-900 transition-colors duration-300 dark:bg-[radial-gradient(circle_at_top_left,_rgba(55,48,43,0.72),_rgba(28,25,23,0.98)_40%,_rgba(12,10,9,1)_100%)] dark:text-stone-100 sm:px-6 sm:pt-2 lg:px-8">
42
  <div className="mx-auto box-border flex min-h-screen max-w-[1440px] flex-col gap-2 pt-[env(safe-area-inset-top)] sm:gap-5 sm:pt-0">
43
  <TopNav />
44
  {children}
web/src/app/login/page.tsx CHANGED
@@ -8,6 +8,7 @@ import { toast } from "sonner";
8
  import { Button } from "@/components/ui/button";
9
  import { Card, CardContent } from "@/components/ui/card";
10
  import { Input } from "@/components/ui/input";
 
11
  import { login } from "@/lib/api";
12
  import { useRedirectIfAuthenticated } from "@/lib/use-auth-guard";
13
  import { getDefaultRouteForRole, setStoredAuthSession } from "@/store/auth";
@@ -53,6 +54,9 @@ export default function LoginPage() {
53
 
54
  return (
55
  <div className="grid min-h-[calc(100vh-1rem)] w-full place-items-center px-4 py-6">
 
 
 
56
  <Card className="w-full max-w-[505px] rounded-[30px] border-white/80 bg-white/95 shadow-[0_28px_90px_rgba(28,25,23,0.10)]">
57
  <CardContent className="space-y-7 p-6 sm:p-8">
58
  <div className="space-y-4 text-center">
 
8
  import { Button } from "@/components/ui/button";
9
  import { Card, CardContent } from "@/components/ui/card";
10
  import { Input } from "@/components/ui/input";
11
+ import { ThemeToggle } from "@/components/theme-toggle";
12
  import { login } from "@/lib/api";
13
  import { useRedirectIfAuthenticated } from "@/lib/use-auth-guard";
14
  import { getDefaultRouteForRole, setStoredAuthSession } from "@/store/auth";
 
54
 
55
  return (
56
  <div className="grid min-h-[calc(100vh-1rem)] w-full place-items-center px-4 py-6">
57
+ <div className="fixed top-4 right-4 z-10">
58
+ <ThemeToggle />
59
+ </div>
60
  <Card className="w-full max-w-[505px] rounded-[30px] border-white/80 bg-white/95 shadow-[0_28px_90px_rgba(28,25,23,0.10)]">
61
  <CardContent className="space-y-7 p-6 sm:p-8">
62
  <div className="space-y-4 text-center">
web/src/components/image-lightbox.tsx CHANGED
@@ -27,6 +27,10 @@ type ImageTransform = {
27
  y: number;
28
  };
29
 
 
 
 
 
30
  type TouchGesture =
31
  | {
32
  type: "swipe";
@@ -54,13 +58,13 @@ function clamp(value: number, min: number, max: number) {
54
  return Math.min(max, Math.max(min, value));
55
  }
56
 
57
- function getTouchDistance(touches: TouchList) {
58
  const first = touches[0];
59
  const second = touches[1];
60
  return Math.hypot(first.clientX - second.clientX, first.clientY - second.clientY);
61
  }
62
 
63
- function getTouchCenter(touches: TouchList) {
64
  const first = touches[0];
65
  const second = touches[1];
66
  return {
 
27
  y: number;
28
  };
29
 
30
+ type TouchPoints = {
31
+ [index: number]: React.Touch;
32
+ };
33
+
34
  type TouchGesture =
35
  | {
36
  type: "swipe";
 
58
  return Math.min(max, Math.max(min, value));
59
  }
60
 
61
+ function getTouchDistance(touches: TouchPoints) {
62
  const first = touches[0];
63
  const second = touches[1];
64
  return Math.hypot(first.clientX - second.clientX, first.clientY - second.clientY);
65
  }
66
 
67
+ function getTouchCenter(touches: TouchPoints) {
68
  const first = touches[0];
69
  const second = touches[1];
70
  return {
web/src/components/theme-script.tsx ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const themeScript = `
2
+ (() => {
3
+ try {
4
+ const stored = localStorage.getItem("chatgpt2api-theme");
5
+ const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
6
+ const theme = stored === "light" || stored === "dark" ? stored : prefersDark ? "dark" : "light";
7
+ document.documentElement.classList.toggle("dark", theme === "dark");
8
+ document.documentElement.style.colorScheme = theme;
9
+ } catch {
10
+ document.documentElement.classList.remove("dark");
11
+ document.documentElement.style.colorScheme = "light";
12
+ }
13
+ })();
14
+ `;
15
+
16
+ export function ThemeScript() {
17
+ return <script dangerouslySetInnerHTML={{ __html: themeScript }} />;
18
+ }
web/src/components/theme-toggle.tsx ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { Moon, Sun } from "lucide-react";
4
+ import { useEffect, useState } from "react";
5
+
6
+ type Theme = "light" | "dark";
7
+
8
+ const STORAGE_KEY = "chatgpt2api-theme";
9
+
10
+ function resolveInitialTheme(): Theme {
11
+ if (typeof window === "undefined") {
12
+ return "light";
13
+ }
14
+ const stored = window.localStorage.getItem(STORAGE_KEY);
15
+ if (stored === "light" || stored === "dark") {
16
+ return stored;
17
+ }
18
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
19
+ }
20
+
21
+ function applyTheme(theme: Theme) {
22
+ document.documentElement.classList.toggle("dark", theme === "dark");
23
+ document.documentElement.style.colorScheme = theme;
24
+ }
25
+
26
+ export function ThemeToggle() {
27
+ const [theme, setTheme] = useState<Theme>("light");
28
+ const isDark = theme === "dark";
29
+
30
+ useEffect(() => {
31
+ const nextTheme = resolveInitialTheme();
32
+ setTheme(nextTheme);
33
+ applyTheme(nextTheme);
34
+ }, []);
35
+
36
+ const toggleTheme = () => {
37
+ const nextTheme: Theme = isDark ? "light" : "dark";
38
+ window.localStorage.setItem(STORAGE_KEY, nextTheme);
39
+ setTheme(nextTheme);
40
+ applyTheme(nextTheme);
41
+ };
42
+
43
+ return (
44
+ <button
45
+ type="button"
46
+ aria-label={isDark ? "切换到浅色模式" : "切换到深色模式"}
47
+ title={isDark ? "浅色模式" : "深色模式"}
48
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-full border border-stone-200/80 bg-white/80 text-stone-500 shadow-sm transition hover:border-stone-300 hover:text-stone-900 dark:border-white/10 dark:bg-white/8 dark:text-stone-300 dark:hover:border-white/20 dark:hover:text-white"
49
+ onClick={toggleTheme}
50
+ >
51
+ {isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
52
+ </button>
53
+ );
54
+ }
web/src/components/top-nav.tsx CHANGED
@@ -6,6 +6,7 @@ import { Github } from "lucide-react";
6
  import { usePathname, useRouter } from "next/navigation";
7
 
8
  import webConfig from "@/constants/common-env";
 
9
  import { getValidatedAuthSession } from "@/lib/auth-session";
10
  import { cn } from "@/lib/utils";
11
  import { clearStoredAuthSession, type StoredAuthSession } from "@/store/auth";
@@ -65,12 +66,12 @@ export function TopNav() {
65
  const displayName = session.name.trim() || roleLabel;
66
 
67
  return (
68
- <header className="border-b border-stone-100/50">
69
  <div className="flex min-h-12 flex-col gap-1 px-3 py-2 sm:h-12 sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-6 sm:py-0">
70
  <div className="flex items-center justify-between gap-2 sm:justify-start sm:gap-3">
71
  <Link
72
  href="/image"
73
- className="shrink-0 py-1 text-[15px] font-bold tracking-tight text-stone-950 transition hover:text-stone-700"
74
  >
75
  chatgpt2api
76
  </Link>
@@ -78,15 +79,18 @@ export function TopNav() {
78
  href="https://github.com/basketikun/chatgpt2api"
79
  target="_blank"
80
  rel="noreferrer"
81
- className="inline-flex items-center gap-1.5 py-1 text-sm text-stone-400 transition hover:text-stone-700"
82
  aria-label="GitHub repository"
83
  >
84
  <Github className="size-4" />
85
  <span className="hidden md:inline">GitHub</span>
86
  </a>
 
 
 
87
  <button
88
  type="button"
89
- className="ml-auto shrink-0 py-1 text-xs text-stone-400 transition hover:text-stone-700 sm:hidden"
90
  onClick={() => void handleLogout()}
91
  >
92
  退出
@@ -102,26 +106,27 @@ export function TopNav() {
102
  className={cn(
103
  "relative shrink-0 whitespace-nowrap rounded-full px-2.5 py-1 text-[13px] font-medium transition sm:rounded-none sm:px-0 sm:text-[15px]",
104
  active
105
- ? "bg-stone-950 text-white sm:bg-transparent sm:font-semibold sm:text-stone-950"
106
- : "text-stone-500 hover:text-stone-900",
107
  )}
108
  >
109
  {item.label}
110
- {active ? <span className="absolute inset-x-0 -bottom-[1px] hidden h-0.5 bg-stone-950 sm:block" /> : null}
111
  </Link>
112
  );
113
  })}
114
  </nav>
115
  <div className="hidden items-center justify-end gap-2 sm:flex sm:gap-3">
116
- <span className="hidden rounded-md bg-stone-100 px-2 py-1 text-[10px] font-medium text-stone-500 sm:inline-block sm:text-[11px]">
 
117
  {roleLabel} · {displayName}
118
  </span>
119
- <span className="hidden rounded-md bg-stone-100 px-2 py-1 text-[10px] font-medium text-stone-500 sm:inline-block sm:text-[11px]">
120
  v{webConfig.appVersion}
121
  </span>
122
  <button
123
  type="button"
124
- className="py-1 text-xs text-stone-400 transition hover:text-stone-700 sm:text-sm"
125
  onClick={() => void handleLogout()}
126
  >
127
  退出
 
6
  import { usePathname, useRouter } from "next/navigation";
7
 
8
  import webConfig from "@/constants/common-env";
9
+ import { ThemeToggle } from "@/components/theme-toggle";
10
  import { getValidatedAuthSession } from "@/lib/auth-session";
11
  import { cn } from "@/lib/utils";
12
  import { clearStoredAuthSession, type StoredAuthSession } from "@/store/auth";
 
66
  const displayName = session.name.trim() || roleLabel;
67
 
68
  return (
69
+ <header className="border-b border-stone-100/50 dark:border-white/10">
70
  <div className="flex min-h-12 flex-col gap-1 px-3 py-2 sm:h-12 sm:flex-row sm:items-center sm:justify-between sm:gap-3 sm:px-6 sm:py-0">
71
  <div className="flex items-center justify-between gap-2 sm:justify-start sm:gap-3">
72
  <Link
73
  href="/image"
74
+ className="shrink-0 py-1 text-[15px] font-bold tracking-tight text-stone-950 transition hover:text-stone-700 dark:text-stone-50 dark:hover:text-white"
75
  >
76
  chatgpt2api
77
  </Link>
 
79
  href="https://github.com/basketikun/chatgpt2api"
80
  target="_blank"
81
  rel="noreferrer"
82
+ className="inline-flex items-center gap-1.5 py-1 text-sm text-stone-400 transition hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
83
  aria-label="GitHub repository"
84
  >
85
  <Github className="size-4" />
86
  <span className="hidden md:inline">GitHub</span>
87
  </a>
88
+ <div className="ml-auto sm:hidden">
89
+ <ThemeToggle />
90
+ </div>
91
  <button
92
  type="button"
93
+ className="shrink-0 py-1 text-xs text-stone-400 transition hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200 sm:hidden"
94
  onClick={() => void handleLogout()}
95
  >
96
  退出
 
106
  className={cn(
107
  "relative shrink-0 whitespace-nowrap rounded-full px-2.5 py-1 text-[13px] font-medium transition sm:rounded-none sm:px-0 sm:text-[15px]",
108
  active
109
+ ? "bg-stone-950 text-white sm:bg-transparent sm:font-semibold sm:text-stone-950 dark:bg-white dark:text-stone-950 dark:sm:bg-transparent dark:sm:text-white"
110
+ : "text-stone-500 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100",
111
  )}
112
  >
113
  {item.label}
114
+ {active ? <span className="absolute inset-x-0 -bottom-[1px] hidden h-0.5 bg-stone-950 dark:bg-white sm:block" /> : null}
115
  </Link>
116
  );
117
  })}
118
  </nav>
119
  <div className="hidden items-center justify-end gap-2 sm:flex sm:gap-3">
120
+ <ThemeToggle />
121
+ <span className="hidden rounded-md bg-stone-100 px-2 py-1 text-[10px] font-medium text-stone-500 dark:bg-white/8 dark:text-stone-300 sm:inline-block sm:text-[11px]">
122
  {roleLabel} · {displayName}
123
  </span>
124
+ <span className="hidden rounded-md bg-stone-100 px-2 py-1 text-[10px] font-medium text-stone-500 dark:bg-white/8 dark:text-stone-300 sm:inline-block sm:text-[11px]">
125
  v{webConfig.appVersion}
126
  </span>
127
  <button
128
  type="button"
129
+ className="py-1 text-xs text-stone-400 transition hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200 sm:text-sm"
130
  onClick={() => void handleLogout()}
131
  >
132
  退出