dsh-plugin-image-workbench 0.0.0-stage → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +258 -0
  3. package/README.md +260 -2
  4. package/client.js +241 -0
  5. package/cordis.patch.yml +14 -0
  6. package/host.js +692 -0
  7. package/install.mjs +333 -0
  8. package/package.json +58 -4
  9. package/scripts/fetch-style-images.mjs +104 -0
  10. package/scripts/vendor-snapshot.py +154 -0
  11. package/server/access.py +26 -0
  12. package/server/app.py +1329 -0
  13. package/server/catalog.json +1 -0
  14. package/server/catalog.py +147 -0
  15. package/server/composer.py +391 -0
  16. package/server/drafts.py +158 -0
  17. package/server/history.py +575 -0
  18. package/server/imaging.py +172 -0
  19. package/server/pool.py +1335 -0
  20. package/server/pool_admin.py +921 -0
  21. package/server/pool_service.py +1073 -0
  22. package/server/requirements.txt +6 -0
  23. package/server/smoke_check.py +402 -0
  24. package/server/static/access.html +202 -0
  25. package/server/static/index.html +4725 -0
  26. package/vendor/chatgpt2api/README.md +13 -0
  27. package/vendor/chatgpt2api/SOURCE.json +11 -0
  28. package/vendor/chatgpt2api/runtime/uv.exe +0 -0
  29. package/vendor/chatgpt2api/source/.env.example +82 -0
  30. package/vendor/chatgpt2api/source/.python-version +1 -0
  31. package/vendor/chatgpt2api/source/LICENSE +21 -0
  32. package/vendor/chatgpt2api/source/README.md +391 -0
  33. package/vendor/chatgpt2api/source/VERSION +1 -0
  34. package/vendor/chatgpt2api/source/api/__init__.py +2 -0
  35. package/vendor/chatgpt2api/source/api/accounts.py +537 -0
  36. package/vendor/chatgpt2api/source/api/ai.py +217 -0
  37. package/vendor/chatgpt2api/source/api/app.py +62 -0
  38. package/vendor/chatgpt2api/source/api/errors.py +46 -0
  39. package/vendor/chatgpt2api/source/api/image_inputs.py +309 -0
  40. package/vendor/chatgpt2api/source/api/image_tasks.py +120 -0
  41. package/vendor/chatgpt2api/source/api/support.py +134 -0
  42. package/vendor/chatgpt2api/source/api/system.py +365 -0
  43. package/vendor/chatgpt2api/source/assets/atlascloud.svg +1 -0
  44. package/vendor/chatgpt2api/source/assets/ipwo.png +0 -0
  45. package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -0
  46. package/vendor/chatgpt2api/source/config.default.json +108 -0
  47. package/vendor/chatgpt2api/source/main.py +9 -0
  48. package/vendor/chatgpt2api/source/pyproject.toml +27 -0
  49. package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -0
  50. package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -0
  51. package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -0
  52. package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -0
  53. package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -0
  54. package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -0
  55. package/vendor/chatgpt2api/source/services/__init__.py +0 -0
  56. package/vendor/chatgpt2api/source/services/account_service.py +1757 -0
  57. package/vendor/chatgpt2api/source/services/auth_service.py +253 -0
  58. package/vendor/chatgpt2api/source/services/backup_service.py +671 -0
  59. package/vendor/chatgpt2api/source/services/config.py +667 -0
  60. package/vendor/chatgpt2api/source/services/content_filter.py +242 -0
  61. package/vendor/chatgpt2api/source/services/cpa_service.py +315 -0
  62. package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -0
  63. package/vendor/chatgpt2api/source/services/image_service.py +376 -0
  64. package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -0
  65. package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -0
  66. package/vendor/chatgpt2api/source/services/image_task_service.py +550 -0
  67. package/vendor/chatgpt2api/source/services/log_service.py +337 -0
  68. package/vendor/chatgpt2api/source/services/model_service.py +182 -0
  69. package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -0
  70. package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -0
  71. package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -0
  72. package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -0
  73. package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -0
  74. package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -0
  75. package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -0
  76. package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -0
  77. package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -0
  78. package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -0
  79. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -0
  80. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -0
  81. package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -0
  82. package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -0
  83. package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -0
  84. package/vendor/chatgpt2api/source/services/proxy_service.py +648 -0
  85. package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -0
  86. package/vendor/chatgpt2api/source/services/storage/base.py +38 -0
  87. package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -0
  88. package/vendor/chatgpt2api/source/services/storage/factory.py +104 -0
  89. package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -0
  90. package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -0
  91. package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -0
  92. package/vendor/chatgpt2api/source/utils/__init__.py +0 -0
  93. package/vendor/chatgpt2api/source/utils/helper.py +469 -0
  94. package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -0
  95. package/vendor/chatgpt2api/source/utils/log.py +110 -0
  96. package/vendor/chatgpt2api/source/utils/pkce.py +19 -0
  97. package/vendor/chatgpt2api/source/utils/pow.py +207 -0
  98. package/vendor/chatgpt2api/source/utils/sentinel.py +159 -0
  99. package/vendor/chatgpt2api/source/utils/turnstile.py +184 -0
  100. package/vendor/chatgpt2api/source/uv.lock +904 -0
  101. package/vendor/chatgpt2api/source/web/bun.lock +1501 -0
  102. package/vendor/chatgpt2api/source/web/components.json +24 -0
  103. package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -0
  104. package/vendor/chatgpt2api/source/web/next.config.ts +40 -0
  105. package/vendor/chatgpt2api/source/web/package.json +58 -0
  106. package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -0
  107. package/vendor/chatgpt2api/source/web/public/github.svg +1 -0
  108. package/vendor/chatgpt2api/source/web/public/next.svg +1 -0
  109. package/vendor/chatgpt2api/source/web/public/openai.svg +1 -0
  110. package/vendor/chatgpt2api/source/web/public/vercel.svg +1 -0
  111. package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -0
  112. package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -0
  113. package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -0
  114. package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -0
  115. package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -0
  116. package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -0
  117. package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -0
  118. package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -0
  119. package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -0
  120. package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -0
  121. package/vendor/chatgpt2api/source/web/src/app/favicon.ico +0 -0
  122. package/vendor/chatgpt2api/source/web/src/app/globals.css +231 -0
  123. package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -0
  124. package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -0
  125. package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -0
  126. package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -0
  127. package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -0
  128. package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -0
  129. package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -0
  130. package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -0
  131. package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -0
  132. package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -0
  133. package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -0
  134. package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -0
  135. package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -0
  136. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -0
  137. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -0
  138. package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -0
  139. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -0
  140. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -0
  141. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -0
  142. package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -0
  143. package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -0
  144. package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -0
  145. package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -0
  146. package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -0
  147. package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -0
  148. package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -0
  149. package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -0
  150. package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -0
  151. package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -0
  152. package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -0
  153. package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -0
  154. package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -0
  155. package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -0
  156. package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -0
  157. package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -0
  158. package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -0
  159. package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -0
  160. package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -0
  161. package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -0
  162. package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -0
  163. package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -0
  164. package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -0
  165. package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -0
  166. package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -0
  167. package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -0
  168. package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -0
  169. package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -0
  170. package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -0
  171. package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -0
  172. package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -0
  173. package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -0
  174. package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -0
  175. package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -0
  176. package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -0
  177. package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -0
  178. package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -0
  179. package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -0
  180. package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -0
  181. package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -0
  182. package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -0
  183. package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -0
  184. package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -0
  185. package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -0
  186. package/vendor/chatgpt2api/source/web/tsconfig.json +42 -0
@@ -0,0 +1,49 @@
1
+ "use client";
2
+
3
+ import { format, parse } from "date-fns";
4
+ import { CalendarIcon } from "lucide-react";
5
+ import type { DateRange } from "react-day-picker";
6
+
7
+ import { Button } from "@/components/ui/button";
8
+ import { Calendar } from "@/components/ui/calendar";
9
+ import { Field } from "@/components/ui/field";
10
+ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
11
+
12
+ type DateRangeFilterProps = {
13
+ startDate: string;
14
+ endDate: string;
15
+ onChange: (startDate: string, endDate: string) => void;
16
+ };
17
+
18
+ export function DateRangeFilter({ startDate, endDate, onChange }: DateRangeFilterProps) {
19
+ const selected: DateRange | undefined = startDate
20
+ ? {
21
+ from: parse(startDate, "yyyy-MM-dd", new Date()),
22
+ to: endDate ? parse(endDate, "yyyy-MM-dd", new Date()) : undefined,
23
+ }
24
+ : undefined;
25
+
26
+ const label = startDate ? `${startDate} 至 ${endDate || startDate}` : "选择日期范围";
27
+
28
+ return (
29
+ <Field className="w-[240px]">
30
+ <Popover>
31
+ <PopoverTrigger asChild>
32
+ <Button variant="outline" className="h-10 justify-start rounded-xl border-stone-200 bg-white px-3 font-normal text-stone-700">
33
+ <CalendarIcon className="size-4 text-stone-400" />
34
+ {label}
35
+ </Button>
36
+ </PopoverTrigger>
37
+ <PopoverContent className="w-auto p-3" align="start">
38
+ <Calendar
39
+ mode="range"
40
+ defaultMonth={selected?.from}
41
+ selected={selected}
42
+ onSelect={(value) => onChange(value?.from ? format(value.from, "yyyy-MM-dd") : "", value?.to ? format(value.to, "yyyy-MM-dd") : "")}
43
+ numberOfMonths={2}
44
+ />
45
+ </PopoverContent>
46
+ </Popover>
47
+ </Field>
48
+ );
49
+ }
@@ -0,0 +1,24 @@
1
+ "use client";
2
+
3
+ import { ThemeToggle } from "@/components/theme-toggle";
4
+ import { VersionReleaseDialog } from "@/components/version-release-dialog";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ export function HeaderActions({ className, showGithubText = true }: { className?: string; showGithubText?: boolean }) {
8
+ return (
9
+ <div className={cn("flex items-center gap-2 sm:gap-3", className)}>
10
+ <ThemeToggle />
11
+ <a
12
+ href="https://github.com/basketikun/chatgpt2api"
13
+ target="_blank"
14
+ rel="noreferrer"
15
+ className="inline-flex h-8 items-center justify-center gap-1.5 text-sm text-stone-500 transition hover:text-stone-900 dark:text-stone-300 dark:hover:text-white"
16
+ aria-label="GitHub repository"
17
+ >
18
+ <img src="/github.svg" alt="" className="size-4" />
19
+ {showGithubText ? <span className="hidden sm:inline">GitHub</span> : null}
20
+ </a>
21
+ <VersionReleaseDialog />
22
+ </div>
23
+ );
24
+ }
@@ -0,0 +1,434 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import * as DialogPrimitive from "@radix-ui/react-dialog";
5
+ import { ChevronLeft, ChevronRight, Download, X } from "lucide-react";
6
+
7
+ import { cn } from "@/lib/utils";
8
+
9
+ type LightboxImage = {
10
+ id: string;
11
+ src: string;
12
+ sizeLabel?: string;
13
+ dimensions?: string;
14
+ };
15
+
16
+ type ImageLightboxProps = {
17
+ images: LightboxImage[];
18
+ currentIndex: number;
19
+ open: boolean;
20
+ onOpenChange: (open: boolean) => void;
21
+ onIndexChange: (index: number) => void;
22
+ };
23
+
24
+ type ImageTransform = {
25
+ scale: number;
26
+ x: number;
27
+ y: number;
28
+ };
29
+
30
+ type TouchPoints = {
31
+ [index: number]: React.Touch;
32
+ };
33
+
34
+ type TouchGesture =
35
+ | {
36
+ type: "swipe";
37
+ startX: number;
38
+ startY: number;
39
+ }
40
+ | {
41
+ type: "pan";
42
+ startX: number;
43
+ startY: number;
44
+ startTransform: ImageTransform;
45
+ }
46
+ | {
47
+ type: "pinch";
48
+ startDistance: number;
49
+ startCenterX: number;
50
+ startCenterY: number;
51
+ startTransform: ImageTransform;
52
+ };
53
+
54
+ const minScale = 1;
55
+ const maxScale = 4;
56
+
57
+ function clamp(value: number, min: number, max: number) {
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 {
71
+ x: (first.clientX + second.clientX) / 2,
72
+ y: (first.clientY + second.clientY) / 2,
73
+ };
74
+ }
75
+
76
+ function normalizeTransform(transform: ImageTransform) {
77
+ if (transform.scale <= minScale) {
78
+ return { scale: minScale, x: 0, y: 0 };
79
+ }
80
+
81
+ const maxX = window.innerWidth * (transform.scale - 1) * 0.5;
82
+ const maxY = window.innerHeight * (transform.scale - 1) * 0.5;
83
+ return {
84
+ scale: transform.scale,
85
+ x: clamp(transform.x, -maxX, maxX),
86
+ y: clamp(transform.y, -maxY, maxY),
87
+ };
88
+ }
89
+
90
+ export function ImageLightbox({
91
+ images,
92
+ currentIndex,
93
+ open,
94
+ onOpenChange,
95
+ onIndexChange,
96
+ }: ImageLightboxProps) {
97
+ const gestureRef = useRef<TouchGesture | null>(null);
98
+ const lastTapRef = useRef(0);
99
+ const pendingTransformRef = useRef<ImageTransform | null>(null);
100
+ const rafRef = useRef<number | null>(null);
101
+ const [transform, setTransform] = useState<ImageTransform>({ scale: 1, x: 0, y: 0 });
102
+ const [isGesturing, setIsGesturing] = useState(false);
103
+ const current = images[currentIndex];
104
+ const hasPrev = currentIndex > 0;
105
+ const hasNext = currentIndex < images.length - 1;
106
+
107
+ const cancelScheduledTransform = useCallback(() => {
108
+ if (rafRef.current != null) {
109
+ cancelAnimationFrame(rafRef.current);
110
+ rafRef.current = null;
111
+ }
112
+ pendingTransformRef.current = null;
113
+ }, []);
114
+
115
+ const scheduleTransform = useCallback((next: ImageTransform) => {
116
+ pendingTransformRef.current = next;
117
+ if (rafRef.current != null) return;
118
+ rafRef.current = requestAnimationFrame(() => {
119
+ rafRef.current = null;
120
+ const pending = pendingTransformRef.current;
121
+ pendingTransformRef.current = null;
122
+ if (pending) {
123
+ setTransform(pending);
124
+ }
125
+ });
126
+ }, []);
127
+
128
+ const flushScheduledTransform = useCallback(() => {
129
+ if (rafRef.current != null) {
130
+ cancelAnimationFrame(rafRef.current);
131
+ rafRef.current = null;
132
+ }
133
+ const pending = pendingTransformRef.current;
134
+ pendingTransformRef.current = null;
135
+ if (pending) {
136
+ setTransform(pending);
137
+ }
138
+ }, []);
139
+
140
+ const resetTransform = useCallback(() => {
141
+ cancelScheduledTransform();
142
+ setTransform({ scale: 1, x: 0, y: 0 });
143
+ setIsGesturing(false);
144
+ gestureRef.current = null;
145
+ }, [cancelScheduledTransform]);
146
+
147
+ const goPrev = useCallback(() => {
148
+ if (hasPrev) onIndexChange(currentIndex - 1);
149
+ }, [hasPrev, currentIndex, onIndexChange]);
150
+
151
+ const goNext = useCallback(() => {
152
+ if (hasNext) onIndexChange(currentIndex + 1);
153
+ }, [hasNext, currentIndex, onIndexChange]);
154
+
155
+ useEffect(() => {
156
+ resetTransform();
157
+ }, [current?.id, open, resetTransform]);
158
+
159
+ useEffect(() => {
160
+ return () => {
161
+ if (rafRef.current != null) {
162
+ cancelAnimationFrame(rafRef.current);
163
+ rafRef.current = null;
164
+ }
165
+ };
166
+ }, []);
167
+
168
+ useEffect(() => {
169
+ if (!open) return;
170
+
171
+ const handleKeyDown = (e: KeyboardEvent) => {
172
+ if (e.key === "ArrowLeft") {
173
+ e.preventDefault();
174
+ goPrev();
175
+ } else if (e.key === "ArrowRight") {
176
+ e.preventDefault();
177
+ goNext();
178
+ }
179
+ };
180
+
181
+ window.addEventListener("keydown", handleKeyDown);
182
+ return () => window.removeEventListener("keydown", handleKeyDown);
183
+ }, [open, goPrev, goNext]);
184
+
185
+ const handleDownload = useCallback(() => {
186
+ if (!current) return;
187
+ const link = document.createElement("a");
188
+ link.href = current.src;
189
+ link.download = `image-${current.id}.png`;
190
+ link.click();
191
+ }, [current]);
192
+
193
+ const toggleZoom = useCallback(() => {
194
+ setTransform((currentTransform) =>
195
+ currentTransform.scale > minScale ? { scale: 1, x: 0, y: 0 } : { scale: 2.5, x: 0, y: 0 },
196
+ );
197
+ }, []);
198
+
199
+ const handleTouchStart = useCallback(
200
+ (event: React.TouchEvent<HTMLDivElement>) => {
201
+ if (event.touches.length === 2) {
202
+ event.preventDefault();
203
+ const startDistance = getTouchDistance(event.touches);
204
+ if (startDistance < 1) {
205
+ gestureRef.current = null;
206
+ return;
207
+ }
208
+ const center = getTouchCenter(event.touches);
209
+ cancelScheduledTransform();
210
+ setIsGesturing(true);
211
+ gestureRef.current = {
212
+ type: "pinch",
213
+ startDistance,
214
+ startCenterX: center.x,
215
+ startCenterY: center.y,
216
+ startTransform: transform,
217
+ };
218
+ return;
219
+ }
220
+
221
+ if (event.touches.length !== 1) {
222
+ gestureRef.current = null;
223
+ return;
224
+ }
225
+
226
+ const touch = event.touches[0];
227
+ if (transform.scale > minScale) {
228
+ cancelScheduledTransform();
229
+ setIsGesturing(true);
230
+ gestureRef.current = {
231
+ type: "pan",
232
+ startX: touch.clientX,
233
+ startY: touch.clientY,
234
+ startTransform: transform,
235
+ };
236
+ } else {
237
+ gestureRef.current = {
238
+ type: "swipe",
239
+ startX: touch.clientX,
240
+ startY: touch.clientY,
241
+ };
242
+ }
243
+ },
244
+ [transform, cancelScheduledTransform],
245
+ );
246
+
247
+ const handleTouchMove = useCallback(
248
+ (event: React.TouchEvent<HTMLDivElement>) => {
249
+ const gesture = gestureRef.current;
250
+ if (!gesture) return;
251
+
252
+ if (gesture.type === "pinch" && event.touches.length === 2) {
253
+ event.preventDefault();
254
+ const targetScale = clamp(
255
+ (getTouchDistance(event.touches) / gesture.startDistance) * gesture.startTransform.scale,
256
+ minScale,
257
+ maxScale,
258
+ );
259
+ const effectiveRatio = targetScale / gesture.startTransform.scale;
260
+ const center = getTouchCenter(event.touches);
261
+ const viewportCenterX = window.innerWidth / 2;
262
+ const viewportCenterY = window.innerHeight / 2;
263
+ const nextX =
264
+ center.x -
265
+ viewportCenterX -
266
+ (gesture.startCenterX - viewportCenterX - gesture.startTransform.x) * effectiveRatio;
267
+ const nextY =
268
+ center.y -
269
+ viewportCenterY -
270
+ (gesture.startCenterY - viewportCenterY - gesture.startTransform.y) * effectiveRatio;
271
+ scheduleTransform(
272
+ normalizeTransform({ scale: targetScale, x: nextX, y: nextY }),
273
+ );
274
+ return;
275
+ }
276
+
277
+ if (gesture.type === "pan" && event.touches.length === 1) {
278
+ event.preventDefault();
279
+ const touch = event.touches[0];
280
+ scheduleTransform(
281
+ normalizeTransform({
282
+ scale: gesture.startTransform.scale,
283
+ x: gesture.startTransform.x + touch.clientX - gesture.startX,
284
+ y: gesture.startTransform.y + touch.clientY - gesture.startY,
285
+ }),
286
+ );
287
+ return;
288
+ }
289
+
290
+ if (event.touches.length !== 1) {
291
+ gestureRef.current = null;
292
+ }
293
+ },
294
+ [scheduleTransform],
295
+ );
296
+
297
+ const handleTouchEnd = useCallback(
298
+ (event: React.TouchEvent<HTMLDivElement>) => {
299
+ flushScheduledTransform();
300
+ setIsGesturing(false);
301
+
302
+ const gesture = gestureRef.current;
303
+ gestureRef.current = null;
304
+ if (!gesture) return;
305
+
306
+ if (gesture.type !== "swipe" || event.changedTouches.length !== 1) {
307
+ return;
308
+ }
309
+
310
+ const touch = event.changedTouches[0];
311
+ const deltaX = touch.clientX - gesture.startX;
312
+ const deltaY = touch.clientY - gesture.startY;
313
+ const now = Date.now();
314
+
315
+ if (Math.abs(deltaX) < 10 && Math.abs(deltaY) < 10 && now - lastTapRef.current < 280) {
316
+ event.preventDefault();
317
+ lastTapRef.current = 0;
318
+ toggleZoom();
319
+ return;
320
+ }
321
+ lastTapRef.current = now;
322
+
323
+ if (Math.abs(deltaX) < 48 || Math.abs(deltaX) < Math.abs(deltaY) * 1.4) {
324
+ return;
325
+ }
326
+
327
+ if (deltaX > 0) {
328
+ goPrev();
329
+ } else {
330
+ goNext();
331
+ }
332
+ },
333
+ [goPrev, goNext, toggleZoom, flushScheduledTransform],
334
+ );
335
+
336
+ const handleTouchCancel = useCallback(() => {
337
+ cancelScheduledTransform();
338
+ setIsGesturing(false);
339
+ gestureRef.current = null;
340
+ }, [cancelScheduledTransform]);
341
+
342
+ if (!current) return null;
343
+
344
+ return (
345
+ <DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
346
+ <DialogPrimitive.Portal>
347
+ <DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
348
+ <DialogPrimitive.Content
349
+ className="fixed inset-0 z-50 flex items-center justify-center outline-none"
350
+ onPointerDownOutside={(e) => e.preventDefault()}
351
+ >
352
+ <DialogPrimitive.Title className="sr-only">
353
+ 图片预览
354
+ </DialogPrimitive.Title>
355
+
356
+ <div className="absolute top-[calc(env(safe-area-inset-top)+1rem)] right-4 z-10 flex items-center gap-2">
357
+ {current.sizeLabel || current.dimensions ? (
358
+ <span className="rounded-full bg-black/50 px-3 py-1.5 text-xs font-medium text-white/90">
359
+ {[current.sizeLabel, current.dimensions].filter(Boolean).join(" · ")}
360
+ </span>
361
+ ) : null}
362
+ {images.length > 1 && (
363
+ <span className="rounded-full bg-black/50 px-3 py-1.5 text-xs font-medium text-white/90">
364
+ {currentIndex + 1} / {images.length}
365
+ </span>
366
+ )}
367
+ <button
368
+ type="button"
369
+ onClick={handleDownload}
370
+ className="inline-flex size-9 items-center justify-center rounded-full bg-black/50 text-white/90 transition hover:bg-black/70"
371
+ aria-label="下载图片"
372
+ >
373
+ <Download className="size-4" />
374
+ </button>
375
+ <DialogPrimitive.Close className="inline-flex size-9 items-center justify-center rounded-full bg-black/50 text-white/90 transition hover:bg-black/70">
376
+ <X className="size-4" />
377
+ <span className="sr-only">关闭</span>
378
+ </DialogPrimitive.Close>
379
+ </div>
380
+
381
+ {hasPrev && transform.scale <= minScale && (
382
+ <button
383
+ type="button"
384
+ onClick={goPrev}
385
+ className="absolute left-4 z-10 inline-flex size-10 items-center justify-center rounded-full bg-black/40 text-white/90 transition hover:bg-black/60"
386
+ aria-label="上一张"
387
+ >
388
+ <ChevronLeft className="size-5" />
389
+ </button>
390
+ )}
391
+
392
+ <div
393
+ className="flex h-full w-full touch-none items-center justify-center overflow-hidden"
394
+ onClick={() => onOpenChange(false)}
395
+ onTouchStart={handleTouchStart}
396
+ onTouchMove={handleTouchMove}
397
+ onTouchEnd={handleTouchEnd}
398
+ onTouchCancel={handleTouchCancel}
399
+ >
400
+ <img
401
+ src={current.src}
402
+ alt=""
403
+ className={cn(
404
+ "max-h-[90vh] max-w-[90vw] rounded-lg object-contain will-change-transform",
405
+ isGesturing ? "" : "transition-transform duration-150 ease-out",
406
+ transform.scale > minScale ? "cursor-grab active:cursor-grabbing" : "cursor-zoom-in",
407
+ )}
408
+ style={{
409
+ transform: `translate3d(${transform.x}px, ${transform.y}px, 0) scale(${transform.scale})`,
410
+ }}
411
+ onClick={(e) => e.stopPropagation()}
412
+ onDoubleClick={(e) => {
413
+ e.stopPropagation();
414
+ toggleZoom();
415
+ }}
416
+ draggable={false}
417
+ />
418
+ </div>
419
+
420
+ {hasNext && transform.scale <= minScale && (
421
+ <button
422
+ type="button"
423
+ onClick={goNext}
424
+ className="absolute right-4 z-10 inline-flex size-10 items-center justify-center rounded-full bg-black/40 text-white/90 transition hover:bg-black/60"
425
+ aria-label="下一张"
426
+ >
427
+ <ChevronRight className="size-5" />
428
+ </button>
429
+ )}
430
+ </DialogPrimitive.Content>
431
+ </DialogPrimitive.Portal>
432
+ </DialogPrimitive.Root>
433
+ );
434
+ }
@@ -0,0 +1,46 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useState } from "react";
4
+
5
+ import { cn } from "@/lib/utils";
6
+
7
+ type ImageThumbnailProps = {
8
+ src: string;
9
+ thumbnailSrc?: string;
10
+ alt?: string;
11
+ className?: string;
12
+ imageClassName?: string;
13
+ };
14
+
15
+ export function getImageThumbnailUrl(src: string) {
16
+ const marker = "/images/";
17
+ const index = src.indexOf(marker);
18
+ if (index < 0) return src;
19
+ return `${src.slice(0, index)}/image-thumbnails/${src.slice(index + marker.length)}`;
20
+ }
21
+
22
+ export function ImageThumbnail({ src, thumbnailSrc, alt = "", className, imageClassName }: ImageThumbnailProps) {
23
+ const initialSrc = useMemo(() => thumbnailSrc || getImageThumbnailUrl(src), [src, thumbnailSrc]);
24
+ const [currentSrc, setCurrentSrc] = useState(initialSrc);
25
+
26
+ useEffect(() => {
27
+ setCurrentSrc(initialSrc);
28
+ }, [initialSrc]);
29
+
30
+ return (
31
+ <span className={cn("block overflow-hidden bg-stone-100", className)}>
32
+ <img
33
+ src={currentSrc}
34
+ alt={alt}
35
+ className={cn("h-full w-full object-cover", imageClassName)}
36
+ loading="lazy"
37
+ decoding="async"
38
+ onError={() => {
39
+ if (currentSrc !== src) {
40
+ setCurrentSrc(src);
41
+ }
42
+ }}
43
+ />
44
+ </span>
45
+ );
46
+ }
@@ -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
+ }
@@ -0,0 +1,14 @@
1
+ "use client";
2
+
3
+ import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler";
4
+
5
+ export function ThemeToggle() {
6
+ return (
7
+ <AnimatedThemeToggler
8
+ aria-label="切换主题"
9
+ title="切换主题"
10
+ variant="circle"
11
+ className="inline-flex size-8 shrink-0 items-center justify-center text-stone-500 transition hover:text-stone-900 dark:text-stone-300 dark:hover:text-white [&_svg]:size-4"
12
+ />
13
+ );
14
+ }