Codex commited on
Commit ·
cf677dd
1
Parent(s): 3931350
feat: 优化前端主题和图片聊天滚动体验
Browse files- web/src/app/globals.css +82 -0
- web/src/app/image/components/image-composer.tsx +3 -4
- web/src/app/image/page.tsx +112 -22
- web/src/app/layout.tsx +9 -2
- web/src/app/login/page.tsx +4 -0
- web/src/components/image-lightbox.tsx +6 -2
- web/src/components/theme-script.tsx +18 -0
- web/src/components/theme-toggle.tsx +54 -0
- web/src/components/top-nav.tsx +15 -10
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
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
}
|
| 486 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 1208 |
-
|
| 1209 |
-
|
| 1210 |
-
|
| 1211 |
-
|
| 1212 |
-
|
| 1213 |
-
|
| 1214 |
-
|
| 1215 |
-
|
| 1216 |
-
|
| 1217 |
-
|
| 1218 |
-
|
| 1219 |
-
|
| 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:
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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:
|
| 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="
|
| 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 |
-
<
|
|
|
|
| 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 |
退出
|