kcg-code 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +230 -2
- package/bin/kcgcode.ts +13 -0
- package/bun-env.d.ts +19 -0
- package/kcg-code.config.example.json +3 -0
- package/package.json +84 -4
- package/public/logo.svg +1 -0
- package/public/manifest.json +18 -0
- package/public/sw.js +49 -0
- package/src/App.tsx +39 -0
- package/src/__tests__/manifest.test.ts +61 -0
- package/src/build-config.ts +9 -0
- package/src/cli/__tests__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +224 -0
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +186 -0
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +327 -0
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +1060 -0
- package/src/cli/index.ts +47 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -0
- package/src/cli/theme.ts +100 -0
- package/src/components/auth/AuthGate.tsx +182 -0
- package/src/components/auth/AvatarEditor.tsx +236 -0
- package/src/components/auth/LockScreen.tsx +714 -0
- package/src/components/auth/ProfileAvatar.tsx +60 -0
- package/src/components/auth/WelcomeScreen.tsx +672 -0
- package/src/components/auth/__tests__/WelcomeScreen.test.tsx +42 -0
- package/src/components/auth/avatar-icons.tsx +114 -0
- package/src/components/layout/AppShell.tsx +147 -0
- package/src/components/layout/AppSidebar.tsx +419 -0
- package/src/components/layout/SearchDialog.tsx +189 -0
- package/src/components/layout/SidebarAccount.tsx +111 -0
- package/src/components/layout/SidebarSessionMenu.tsx +163 -0
- package/src/components/layout/ThemeToggle.tsx +67 -0
- package/src/components/projects/FolderBrowser.tsx +142 -0
- package/src/components/projects/HomeChat.tsx +287 -0
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/NewProjectDialog.tsx +249 -0
- package/src/components/projects/ProjectInstructions.tsx +218 -0
- package/src/components/projects/ProjectRow.tsx +58 -0
- package/src/components/projects/ProjectSidePanel.tsx +514 -0
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentModeList.tsx +265 -0
- package/src/components/sessions/AgentPicker.tsx +132 -0
- package/src/components/sessions/AssistantTurn.tsx +145 -0
- package/src/components/sessions/AttachmentTile.tsx +161 -0
- package/src/components/sessions/CodeBlock.tsx +107 -0
- package/src/components/sessions/ComposerActionSheet.tsx +96 -0
- package/src/components/sessions/ConfirmDeleteProjectDialog.tsx +80 -0
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +92 -0
- package/src/components/sessions/ContextPanel.tsx +661 -0
- package/src/components/sessions/DropPlate.tsx +165 -0
- package/src/components/sessions/FileDropOverlay.tsx +232 -0
- package/src/components/sessions/FileMentionDropdown.tsx +47 -0
- package/src/components/sessions/ImageLightbox.tsx +173 -0
- package/src/components/sessions/MarkdownContent.tsx +187 -0
- package/src/components/sessions/Mascot.tsx +47 -0
- package/src/components/sessions/MessageActions.tsx +60 -0
- package/src/components/sessions/MessageRail.tsx +121 -0
- package/src/components/sessions/ModelPicker.tsx +247 -0
- package/src/components/sessions/ModelSearchList.tsx +231 -0
- package/src/components/sessions/NewSessionDialog.tsx +172 -0
- package/src/components/sessions/PendingAttachments.tsx +127 -0
- package/src/components/sessions/PromptCard.tsx +1085 -0
- package/src/components/sessions/PromptPanel.tsx +112 -0
- package/src/components/sessions/SessionComposer.tsx +641 -0
- package/src/components/sessions/SessionHeader.tsx +176 -0
- package/src/components/sessions/SessionList.tsx +503 -0
- package/src/components/sessions/SessionRow.tsx +237 -0
- package/src/components/sessions/SessionTimeline.tsx +275 -0
- package/src/components/sessions/SessionTimelineSkeleton.tsx +83 -0
- package/src/components/sessions/SessionView.tsx +240 -0
- package/src/components/sessions/ThoughtProcessBlock.tsx +199 -0
- package/src/components/sessions/UserMessage.tsx +177 -0
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +256 -0
- package/src/components/sessions/__tests__/MarkdownContent.test.tsx +89 -0
- package/src/components/sessions/__tests__/MessageRail.test.tsx +40 -0
- package/src/components/sessions/__tests__/PermissionCard.test.tsx +79 -0
- package/src/components/sessions/__tests__/PromptCard.test.ts +166 -0
- package/src/components/sessions/__tests__/QuestionCard.test.tsx +166 -0
- package/src/components/sessions/__tests__/SessionTimelineSkeleton.test.tsx +32 -0
- package/src/components/sessions/__tests__/SessionView.test.ts +386 -0
- package/src/components/settings/AboutGroup.tsx +170 -0
- package/src/components/settings/AccountGroup.tsx +122 -0
- package/src/components/settings/DevicesGroup.tsx +151 -0
- package/src/components/settings/LhrRemoteAccess.tsx +257 -0
- package/src/components/settings/PasskeysGroup.tsx +551 -0
- package/src/components/settings/RemoteAccessGroup.tsx +1201 -0
- package/src/components/settings/SecurityGroup.tsx +566 -0
- package/src/components/settings/WorkspaceGroup.tsx +370 -0
- package/src/components/settings/__tests__/AboutGroup.test.tsx +47 -0
- package/src/components/settings/__tests__/PasskeysGroup.test.tsx +109 -0
- package/src/components/settings/__tests__/WorkspaceGroup.test.tsx +49 -0
- package/src/components/settings/prefs.tsx +333 -0
- package/src/components/skills/InstallNotifications.tsx +326 -0
- package/src/components/skills/InstallSkillDialog.tsx +390 -0
- package/src/components/skills/SkillCards.tsx +254 -0
- package/src/components/skills/SkillsProjectPicker.tsx +178 -0
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -0
- package/src/components/ui/alert-dialog.tsx +179 -0
- package/src/components/ui/alert.tsx +60 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/bubble.tsx +125 -0
- package/src/components/ui/button.tsx +62 -0
- package/src/components/ui/card.tsx +75 -0
- package/src/components/ui/collapsible.tsx +19 -0
- package/src/components/ui/dialog.tsx +142 -0
- package/src/components/ui/dropdown-menu.tsx +225 -0
- package/src/components/ui/empty.tsx +94 -0
- package/src/components/ui/field.tsx +234 -0
- package/src/components/ui/input-group.tsx +161 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +19 -0
- package/src/components/ui/message-scroller.tsx +131 -0
- package/src/components/ui/message.tsx +85 -0
- package/src/components/ui/popover.tsx +46 -0
- package/src/components/ui/search-entry.tsx +70 -0
- package/src/components/ui/select.tsx +172 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/sheet.tsx +138 -0
- package/src/components/ui/sidebar.tsx +699 -0
- package/src/components/ui/skeleton.tsx +15 -0
- package/src/components/ui/sonner.tsx +45 -0
- package/src/components/ui/spinner.tsx +16 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/config.ts +114 -0
- package/src/db.ts +146 -0
- package/src/frontend.tsx +51 -0
- package/src/hooks/__tests__/useMention.test.ts +79 -0
- package/src/hooks/__tests__/useResyncOnReconnect.test.ts +128 -0
- package/src/hooks/__tests__/useRouter.test.ts +17 -0
- package/src/hooks/__tests__/useSmoothText.react.test.ts +194 -0
- package/src/hooks/__tests__/useSmoothText.test.ts +61 -0
- package/src/hooks/__tests__/useTheme.test.ts +118 -0
- package/src/hooks/use-mobile.ts +16 -0
- package/src/hooks/useAgentPicker.ts +101 -0
- package/src/hooks/useMediaQuery.ts +49 -0
- package/src/hooks/useMention.ts +236 -0
- package/src/hooks/useProjects.ts +88 -0
- package/src/hooks/useResyncOnReconnect.ts +28 -0
- package/src/hooks/useRouter.ts +92 -0
- package/src/hooks/useSessionChat.ts +427 -0
- package/src/hooks/useSessionContext.ts +106 -0
- package/src/hooks/useSessionList.ts +327 -0
- package/src/hooks/useSidebarData.ts +108 -0
- package/src/hooks/useSmoothText.ts +122 -0
- package/src/hooks/useTheme.ts +164 -0
- package/src/hooks/useWebSocket.ts +196 -0
- package/src/index.css +27 -0
- package/src/index.html +14 -0
- package/src/index.ts +49 -0
- package/src/lib/__tests__/attachments.test.ts +58 -0
- package/src/lib/__tests__/avatar-presets.test.ts +25 -0
- package/src/lib/__tests__/collapsible.test.ts +101 -0
- package/src/lib/__tests__/composer.test.ts +118 -0
- package/src/lib/__tests__/highlight.test.ts +42 -0
- package/src/lib/__tests__/home-chat.test.ts +64 -0
- package/src/lib/__tests__/lock-screen.test.ts +64 -0
- package/src/lib/__tests__/model-picker.test.ts +205 -0
- package/src/lib/__tests__/onboarding.test.ts +41 -0
- package/src/lib/__tests__/permission-info.test.ts +61 -0
- package/src/lib/__tests__/project-extensions.test.ts +83 -0
- package/src/lib/__tests__/project-overview.test.ts +188 -0
- package/src/lib/__tests__/references.test.ts +121 -0
- package/src/lib/__tests__/routes.test.ts +134 -0
- package/src/lib/__tests__/session-selection.test.ts +41 -0
- package/src/lib/__tests__/session-summary.test.ts +115 -0
- package/src/lib/__tests__/session-title.test.ts +58 -0
- package/src/lib/__tests__/sidebar-groups.test.ts +100 -0
- package/src/lib/__tests__/skill-installs.test.ts +135 -0
- package/src/lib/__tests__/skills-catalog.test.ts +124 -0
- package/src/lib/__tests__/tone.test.ts +37 -0
- package/src/lib/__tests__/turns-delta.test.ts +55 -0
- package/src/lib/agent-types.ts +49 -0
- package/src/lib/api.ts +306 -0
- package/src/lib/attachments.ts +203 -0
- package/src/lib/auth.ts +92 -0
- package/src/lib/avatar-presets.ts +31 -0
- package/src/lib/collapsible.ts +75 -0
- package/src/lib/composer.ts +89 -0
- package/src/lib/data-events.ts +17 -0
- package/src/lib/headlines.ts +30 -0
- package/src/lib/highlight.ts +186 -0
- package/src/lib/layout.ts +20 -0
- package/src/lib/lock-screen.ts +54 -0
- package/src/lib/model-picker.ts +133 -0
- package/src/lib/onboarding.ts +38 -0
- package/src/lib/passkeys.ts +113 -0
- package/src/lib/pending-prompt.ts +53 -0
- package/src/lib/permission-info.ts +101 -0
- package/src/lib/project-extensions.ts +136 -0
- package/src/lib/project-overview.ts +123 -0
- package/src/lib/prompts.ts +87 -0
- package/src/lib/references.ts +106 -0
- package/src/lib/routes.ts +124 -0
- package/src/lib/session-handoff.ts +25 -0
- package/src/lib/session-selection.ts +40 -0
- package/src/lib/session-status.ts +45 -0
- package/src/lib/session-summary.ts +68 -0
- package/src/lib/session-title.ts +63 -0
- package/src/lib/sidebar-groups.ts +78 -0
- package/src/lib/skill-installs.ts +362 -0
- package/src/lib/skills-catalog.ts +138 -0
- package/src/lib/time.ts +22 -0
- package/src/lib/tone.ts +80 -0
- package/src/lib/turns.ts +320 -0
- package/src/lib/utils.ts +6 -0
- package/src/logo.svg +31 -0
- package/src/pages/projects/ProjectDetailPage.tsx +131 -0
- package/src/pages/projects/ProjectsPage.tsx +97 -0
- package/src/pages/sessions/SessionPage.tsx +122 -0
- package/src/pages/settings/SettingsPage.tsx +366 -0
- package/src/pages/settings/__tests__/SettingsPage.test.ts +16 -0
- package/src/pages/skills/SkillsPage.tsx +745 -0
- package/src/paths.ts +121 -0
- package/src/runtime.ts +24 -0
- package/src/server/__tests__/app.test.ts +1044 -0
- package/src/server/__tests__/auth.test.ts +370 -0
- package/src/server/__tests__/config.test.ts +101 -0
- package/src/server/__tests__/db.test.ts +485 -0
- package/src/server/__tests__/host.test.ts +14 -0
- package/src/server/__tests__/tunnel-config.test.ts +38 -0
- package/src/server/app.ts +427 -0
- package/src/server/db/auth.ts +311 -0
- package/src/server/db/messages.ts +50 -0
- package/src/server/db/projects.ts +106 -0
- package/src/server/db/prompts.ts +78 -0
- package/src/server/db/rows.ts +150 -0
- package/src/server/db/schema.ts +221 -0
- package/src/server/db/sessions.ts +239 -0
- package/src/server/db/tunnel.ts +124 -0
- package/src/server/host.ts +12 -0
- package/src/server/result.ts +15 -0
- package/src/server/routes/auth-guard.ts +156 -0
- package/src/server/routes/auth.routes.ts +287 -0
- package/src/server/routes/helpers.ts +165 -0
- package/src/server/routes/meta.routes.ts +24 -0
- package/src/server/routes/passkeys.routes.ts +161 -0
- package/src/server/routes/projects.routes.ts +167 -0
- package/src/server/routes/sessions.routes.ts +197 -0
- package/src/server/routes/skills.routes.ts +136 -0
- package/src/server/routes/tunnel.routes.ts +132 -0
- package/src/server/routes/types.ts +35 -0
- package/src/server/routes/uploads.routes.ts +54 -0
- package/src/server/routes/workspace.routes.ts +127 -0
- package/src/server/services/__tests__/app-info.test.ts +18 -0
- package/src/server/services/__tests__/attachments.test.ts +159 -0
- package/src/server/services/__tests__/auth-passkeys.test.ts +358 -0
- package/src/server/services/__tests__/auth.test.ts +341 -0
- package/src/server/services/__tests__/claude-code.test.ts +609 -0
- package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
- package/src/server/services/__tests__/claude-skills.test.ts +63 -0
- package/src/server/services/__tests__/frpc-installer.test.ts +83 -0
- package/src/server/services/__tests__/git-info.test.ts +74 -0
- package/src/server/services/__tests__/opencode.test.ts +320 -0
- package/src/server/services/__tests__/project-manager.test.ts +190 -0
- package/src/server/services/__tests__/sandbox.test.ts +141 -0
- package/src/server/services/__tests__/session-manager.test.ts +3509 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +245 -0
- package/src/server/services/__tests__/skills-registry.test.ts +365 -0
- package/src/server/services/__tests__/tunnel-lhr.test.ts +234 -0
- package/src/server/services/__tests__/tunnel.test.ts +367 -0
- package/src/server/services/__tests__/websocket-gateway.test.ts +677 -0
- package/src/server/services/__tests__/workspace.test.ts +249 -0
- package/src/server/services/app-info.ts +29 -0
- package/src/server/services/attachments.ts +193 -0
- package/src/server/services/auth-passkeys.ts +459 -0
- package/src/server/services/auth.ts +633 -0
- package/src/server/services/claude-code-client.ts +1093 -0
- package/src/server/services/claude-code-translate.ts +370 -0
- package/src/server/services/claude-mcp.ts +122 -0
- package/src/server/services/claude-skills.ts +102 -0
- package/src/server/services/frpc-installer.ts +183 -0
- package/src/server/services/git-info.ts +87 -0
- package/src/server/services/opencode-client.ts +714 -0
- package/src/server/services/opencode-server.ts +319 -0
- package/src/server/services/opencode-sse.ts +82 -0
- package/src/server/services/project-manager.ts +147 -0
- package/src/server/services/sandbox.ts +83 -0
- package/src/server/services/session-events.ts +256 -0
- package/src/server/services/session-manager.ts +1439 -0
- package/src/server/services/skill-install-jobs.ts +275 -0
- package/src/server/services/skills-registry.ts +480 -0
- package/src/server/services/tunnel-api.ts +207 -0
- package/src/server/services/tunnel-config.ts +45 -0
- package/src/server/services/tunnel-lhr.ts +387 -0
- package/src/server/services/tunnel.ts +633 -0
- package/src/server/services/turn-stream.ts +339 -0
- package/src/server/services/websocket-gateway.ts +374 -0
- package/src/server/services/workspace.ts +223 -0
- package/src/server/services/ws-transport.ts +121 -0
- package/src/types/context.ts +69 -0
- package/src/types/index.ts +13 -0
- package/src/types/message.ts +58 -0
- package/src/types/project.ts +16 -0
- package/src/types/prompt.ts +118 -0
- package/src/types/session.ts +52 -0
- package/src/types/turns.ts +47 -0
- package/src/ws-protocol.ts +93 -0
- package/styles/globals.css +311 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Plate" animasi drop file: saat file dilepas, kartu "Add anything"
|
|
3
|
+
* berubah menjadi plate berisi tumpukan file; plate lalu MENYUSUT menjadi
|
|
4
|
+
* kotak composer, sementara tiap file terbang ke slot preview-nya masing-
|
|
5
|
+
* masing di atas input.
|
|
6
|
+
*
|
|
7
|
+
* Kenapa terbaca sebagai "geser", bukan "fade":
|
|
8
|
+
* - Setiap file punya perjalanan sendiri: dari tumpukan besar di tengah
|
|
9
|
+
* kartu (skala besar, sedikit miring, bertumpuk) ke slot aslinya (skala 1).
|
|
10
|
+
* Perubahan skala + posisi + rotasi inilah yang dilihat mata sebagai
|
|
11
|
+
* perpindahan. Satu grup yang ditranslasi utuh (versi lama) tidak
|
|
12
|
+
* menyusut bila isinya sudah lebar, jadi hanya tampak bergeser lalu hilang.
|
|
13
|
+
* - Latar plate mendarat tepat di KOTAK COMPOSER (warna & sudut serupa),
|
|
14
|
+
* lalu memudar ke dalamnya — tidak ada kotak besar yang "menghilang" di
|
|
15
|
+
* ruang kosong.
|
|
16
|
+
*
|
|
17
|
+
* FLIP tanpa fitur `layout` Motion (`domAnimation`): composer merender item
|
|
18
|
+
* baru tak terlihat, mengukur rect akhir tiap item + kotak composer, lalu
|
|
19
|
+
* komponen ini merender salinan item di posisi akhir dan menganimasikan
|
|
20
|
+
* transform dari posisi tumpukan menuju identitas. Selesai -> `onDone`
|
|
21
|
+
* memunculkan item asli di frame yang sama (posisinya identik).
|
|
22
|
+
*
|
|
23
|
+
* Portal ke `document.body` (position fixed tidak boleh terjebak ancestor
|
|
24
|
+
* ber-transform). Reduce motion -> dilewati langsung.
|
|
25
|
+
*/
|
|
26
|
+
import { useReducedMotion } from "motion/react";
|
|
27
|
+
import * as m from "motion/react-m";
|
|
28
|
+
import { useEffect } from "react";
|
|
29
|
+
import { createPortal } from "react-dom";
|
|
30
|
+
import { AttachmentVisual, type PendingAttachment } from "@/components/sessions/PendingAttachments";
|
|
31
|
+
|
|
32
|
+
export interface DropPlateProps {
|
|
33
|
+
/** Rect kartu overlay saat file dilepas (viewport). */
|
|
34
|
+
from: DOMRect;
|
|
35
|
+
/** Rect akhir tiap item di deretan lampiran (viewport). */
|
|
36
|
+
items: { item: PendingAttachment; rect: DOMRect }[];
|
|
37
|
+
/** Rect kotak composer; null -> plate menyusut ke gabungan item. */
|
|
38
|
+
box: DOMRect | null;
|
|
39
|
+
onDone: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Rect gabungan beberapa rect. */
|
|
43
|
+
function unionRect(rects: DOMRect[]): { x: number; y: number; w: number; h: number } {
|
|
44
|
+
const x = Math.min(...rects.map((r) => r.left));
|
|
45
|
+
const y = Math.min(...rects.map((r) => r.top));
|
|
46
|
+
const r = Math.max(...rects.map((q) => q.right));
|
|
47
|
+
const b = Math.max(...rects.map((q) => q.bottom));
|
|
48
|
+
return { x, y, w: r - x, h: b - y };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Jeda sebelum terbang: file terlihat "masuk" ke plate (detik). */
|
|
52
|
+
const HOLD = 0.12;
|
|
53
|
+
/** Durasi visual terbang (detik). */
|
|
54
|
+
const FLY = 0.55;
|
|
55
|
+
/** Ukuran tumpukan di tengah kartu: sisi terpanjang item = porsi kartu ini. */
|
|
56
|
+
const PILE_FILL = 0.42;
|
|
57
|
+
/** Maksimal item yang tampak di tumpukan; sisanya menumpuk di belakang. */
|
|
58
|
+
const PILE_SPREAD = 3;
|
|
59
|
+
/** Jeda antar file saat terbang (detik) & batasnya. */
|
|
60
|
+
const STAGGER = 0.035;
|
|
61
|
+
const MAX_STAGGER = 0.12;
|
|
62
|
+
|
|
63
|
+
export function DropPlate({ from, items, box, onDone }: DropPlateProps) {
|
|
64
|
+
const reduce = useReducedMotion();
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (reduce) onDone();
|
|
68
|
+
}, [reduce, onDone]);
|
|
69
|
+
// Selesai berdasarkan waktu, bukan `onAnimationComplete`: spring baru
|
|
70
|
+
// "diam" ±0.5 detik setelah gerakannya tak lagi terlihat, dan selama itu
|
|
71
|
+
// preview asli masih tersembunyi (tidak bisa diklik / dihapus). Salinan
|
|
72
|
+
// sudah berada di posisi akhir saat ini, jadi pergantiannya tak terlihat.
|
|
73
|
+
// Sekaligus jaring pengaman bila animasi dibatalkan (tab di latar).
|
|
74
|
+
const endMs = (HOLD + Math.min((items.length - 1) * STAGGER, MAX_STAGGER) + FLY) * 1000 + 60;
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
const id = setTimeout(onDone, endMs);
|
|
77
|
+
return () => clearTimeout(id);
|
|
78
|
+
}, [onDone, endMs]);
|
|
79
|
+
if (reduce || items.length === 0) return null;
|
|
80
|
+
|
|
81
|
+
const cx = from.left + from.width / 2;
|
|
82
|
+
const cy = from.top + from.height / 2;
|
|
83
|
+
const land = box
|
|
84
|
+
? { x: box.left, y: box.top, w: box.width, h: box.height }
|
|
85
|
+
: unionRect(items.map((i) => i.rect));
|
|
86
|
+
|
|
87
|
+
return createPortal(
|
|
88
|
+
<div aria-hidden className="pointer-events-none fixed inset-0 z-50">
|
|
89
|
+
{/* Latar plate: kartu -> kotak composer, lalu larut ke dalamnya. */}
|
|
90
|
+
<m.div
|
|
91
|
+
className="absolute top-0 left-0 border bg-popover shadow-2xl will-change-transform"
|
|
92
|
+
initial={{
|
|
93
|
+
x: from.left,
|
|
94
|
+
y: from.top,
|
|
95
|
+
width: from.width,
|
|
96
|
+
height: from.height,
|
|
97
|
+
borderRadius: 28,
|
|
98
|
+
opacity: 1,
|
|
99
|
+
}}
|
|
100
|
+
animate={{
|
|
101
|
+
x: land.x,
|
|
102
|
+
y: land.y,
|
|
103
|
+
width: land.w,
|
|
104
|
+
height: land.h,
|
|
105
|
+
borderRadius: 12,
|
|
106
|
+
opacity: 0,
|
|
107
|
+
}}
|
|
108
|
+
transition={{
|
|
109
|
+
default: { type: "spring", visualDuration: FLY, bounce: 0, delay: HOLD },
|
|
110
|
+
// Memudar hanya di ekor perjalanan: saat itu ukurannya sudah
|
|
111
|
+
// sama dengan kotak composer, jadi yang terlihat adalah plate
|
|
112
|
+
// "menjadi" composer, bukan kotak yang lenyap.
|
|
113
|
+
opacity: { duration: 0.18, delay: HOLD + FLY * 0.8, ease: "easeOut" },
|
|
114
|
+
}}
|
|
115
|
+
/>
|
|
116
|
+
|
|
117
|
+
{/* Tiap file: dari tumpukan di tengah kartu ke slot preview-nya. */}
|
|
118
|
+
{items.map(({ item, rect }, i) => {
|
|
119
|
+
// Skala tumpukan: item terlihat besar di kartu, tapi tidak lebih
|
|
120
|
+
// besar dari porsi kartu.
|
|
121
|
+
const pileScale = Math.min(
|
|
122
|
+
2.6,
|
|
123
|
+
Math.max(
|
|
124
|
+
1.3,
|
|
125
|
+
(Math.min(from.width, from.height) * PILE_FILL) / Math.max(rect.width, rect.height),
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
// Posisi dalam tumpukan: sedikit menyebar & miring bergantian.
|
|
129
|
+
const slot = Math.min(i, PILE_SPREAD - 1) - (Math.min(items.length, PILE_SPREAD) - 1) / 2;
|
|
130
|
+
const offsetX = slot * 26;
|
|
131
|
+
const offsetY = Math.abs(slot) * 6;
|
|
132
|
+
const rotate = slot * 7;
|
|
133
|
+
// Transform relatif ke posisi akhir (pusat item -> pusat tumpukan).
|
|
134
|
+
const dx = cx + offsetX - (rect.left + rect.width / 2);
|
|
135
|
+
const dy = cy + offsetY - (rect.top + rect.height / 2) - from.height * 0.06;
|
|
136
|
+
const delay = HOLD + Math.min(i * STAGGER, MAX_STAGGER);
|
|
137
|
+
return (
|
|
138
|
+
<m.div
|
|
139
|
+
key={item.key}
|
|
140
|
+
className="absolute will-change-transform"
|
|
141
|
+
style={{
|
|
142
|
+
left: rect.left,
|
|
143
|
+
top: rect.top,
|
|
144
|
+
width: rect.width,
|
|
145
|
+
height: rect.height,
|
|
146
|
+
zIndex: items.length - i,
|
|
147
|
+
}}
|
|
148
|
+
initial={{ x: dx, y: dy, scale: pileScale, rotate, opacity: 0 }}
|
|
149
|
+
animate={{ x: 0, y: 0, scale: 1, rotate: 0, opacity: 1 }}
|
|
150
|
+
transition={{
|
|
151
|
+
default: { type: "spring", visualDuration: FLY, bounce: 0.12, delay },
|
|
152
|
+
// Muncul cepat di tumpukan (pop), lalu tetap solid sepanjang jalan.
|
|
153
|
+
opacity: { duration: 0.1 },
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
<div className="size-full shadow-lg [&>*]:shadow-none" style={{ borderRadius: 10 }}>
|
|
157
|
+
<AttachmentVisual item={item} />
|
|
158
|
+
</div>
|
|
159
|
+
</m.div>
|
|
160
|
+
);
|
|
161
|
+
})}
|
|
162
|
+
</div>,
|
|
163
|
+
document.body,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlay drag & drop file untuk layar Session (ala "Add anything").
|
|
3
|
+
*
|
|
4
|
+
* - Aktif hanya saat yang di-drag berisi file dari luar halaman (bukan teks
|
|
5
|
+
* / gambar yang di-drag di dalam aplikasi).
|
|
6
|
+
* - Seluruh layar diredupkan (termasuk sidebar) lewat portal `fixed` ke
|
|
7
|
+
* `document.body`; kartu naik dari bawah dan berhenti tepat di tengah
|
|
8
|
+
* viewport: ilustrasi tumpukan file, judul, dan keterangan. Tidak bisa
|
|
9
|
+
* menerima (Session mati / model sedang merespon) -> kartu memberi tahu
|
|
10
|
+
* alasannya, drop diabaikan.
|
|
11
|
+
* - Saat file dilepas, `onDropFiles` menerima file + posisi kartu. Kartu
|
|
12
|
+
* langsung menghilang tanpa animasi keluar karena "plate" di composer
|
|
13
|
+
* (`DropPlate`) mengambil alih dari posisi yang sama lalu mengecil ke
|
|
14
|
+
* deretan lampiran.
|
|
15
|
+
*
|
|
16
|
+
* Listener dipasang di `window` (drop di mana pun selama layar Session
|
|
17
|
+
* terbuka) dan `preventDefault` selalu dipanggil untuk drag file agar
|
|
18
|
+
* browser tidak membuka file di tab ini.
|
|
19
|
+
*/
|
|
20
|
+
import { FileTextIcon, ImageIcon, SquareTerminalIcon } from "lucide-react";
|
|
21
|
+
import { AnimatePresence } from "motion/react";
|
|
22
|
+
import * as m from "motion/react-m";
|
|
23
|
+
import { useEffect, useRef, useState } from "react";
|
|
24
|
+
import { createPortal } from "react-dom";
|
|
25
|
+
import { cn } from "@/lib/utils";
|
|
26
|
+
|
|
27
|
+
function hasFiles(e: DragEvent): boolean {
|
|
28
|
+
return e.dataTransfer?.types.includes("Files") ?? false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface FileDropOverlayProps {
|
|
32
|
+
/** Composer bisa menerima lampiran sekarang. */
|
|
33
|
+
accepting: boolean;
|
|
34
|
+
/** Keterangan saat tidak bisa menerima (mis. "Session is not active"). */
|
|
35
|
+
blockedReason?: string;
|
|
36
|
+
onDropFiles: (files: File[], from: DOMRect | null) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type Phase = "idle" | "dragging" | "dropped";
|
|
40
|
+
|
|
41
|
+
export function FileDropOverlay({ accepting, blockedReason, onDropFiles }: FileDropOverlayProps) {
|
|
42
|
+
const [phase, setPhase] = useState<Phase>("idle");
|
|
43
|
+
const cardRef = useRef<HTMLDivElement | null>(null);
|
|
44
|
+
// Nilai terbaru untuk listener window (dipasang sekali).
|
|
45
|
+
const latest = useRef({ accepting, onDropFiles });
|
|
46
|
+
latest.current = { accepting, onDropFiles };
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
/** dragenter/dragleave berpasangan per elemen — hitung kedalaman. */
|
|
50
|
+
let depth = 0;
|
|
51
|
+
/** Drag dimulai di dalam halaman (bukan file dari luar) -> abaikan. */
|
|
52
|
+
let internal = false;
|
|
53
|
+
|
|
54
|
+
const onDragStart = () => {
|
|
55
|
+
internal = true;
|
|
56
|
+
};
|
|
57
|
+
const onDragEnd = () => {
|
|
58
|
+
internal = false;
|
|
59
|
+
};
|
|
60
|
+
const onEnter = (e: DragEvent) => {
|
|
61
|
+
if (internal || !hasFiles(e)) return;
|
|
62
|
+
depth++;
|
|
63
|
+
setPhase("dragging");
|
|
64
|
+
};
|
|
65
|
+
const onOver = (e: DragEvent) => {
|
|
66
|
+
if (internal || !hasFiles(e)) return;
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = latest.current.accepting ? "copy" : "none";
|
|
69
|
+
};
|
|
70
|
+
const onLeave = (e: DragEvent) => {
|
|
71
|
+
if (internal || !hasFiles(e)) return;
|
|
72
|
+
depth = Math.max(0, depth - 1);
|
|
73
|
+
if (depth === 0) setPhase("idle");
|
|
74
|
+
};
|
|
75
|
+
const onDrop = (e: DragEvent) => {
|
|
76
|
+
if (internal || !hasFiles(e)) return;
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
depth = 0;
|
|
79
|
+
const files = e.dataTransfer ? [...e.dataTransfer.files] : [];
|
|
80
|
+
if (files.length === 0 || !latest.current.accepting) {
|
|
81
|
+
setPhase("idle");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const rect = cardRef.current?.getBoundingClientRect() ?? null;
|
|
85
|
+
setPhase("dropped");
|
|
86
|
+
latest.current.onDropFiles(files, rect);
|
|
87
|
+
};
|
|
88
|
+
// Jendela kehilangan fokus / Esc membatalkan drag -> tutup overlay.
|
|
89
|
+
const reset = () => {
|
|
90
|
+
depth = 0;
|
|
91
|
+
internal = false;
|
|
92
|
+
setPhase("idle");
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
window.addEventListener("dragstart", onDragStart);
|
|
96
|
+
window.addEventListener("dragend", onDragEnd);
|
|
97
|
+
window.addEventListener("dragenter", onEnter);
|
|
98
|
+
window.addEventListener("dragover", onOver);
|
|
99
|
+
window.addEventListener("dragleave", onLeave);
|
|
100
|
+
window.addEventListener("drop", onDrop);
|
|
101
|
+
window.addEventListener("blur", reset);
|
|
102
|
+
return () => {
|
|
103
|
+
window.removeEventListener("dragstart", onDragStart);
|
|
104
|
+
window.removeEventListener("dragend", onDragEnd);
|
|
105
|
+
window.removeEventListener("dragenter", onEnter);
|
|
106
|
+
window.removeEventListener("dragover", onOver);
|
|
107
|
+
window.removeEventListener("dragleave", onLeave);
|
|
108
|
+
window.removeEventListener("drop", onDrop);
|
|
109
|
+
window.removeEventListener("blur", reset);
|
|
110
|
+
};
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
// "dropped" hanya perlu satu frame agar kartu keluar tanpa animasi.
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (phase !== "dropped") return;
|
|
116
|
+
const id = requestAnimationFrame(() => setPhase("idle"));
|
|
117
|
+
return () => cancelAnimationFrame(id);
|
|
118
|
+
}, [phase]);
|
|
119
|
+
|
|
120
|
+
const dropped = phase === "dropped";
|
|
121
|
+
|
|
122
|
+
return createPortal(
|
|
123
|
+
<AnimatePresence custom={dropped}>
|
|
124
|
+
{phase === "dragging" && (
|
|
125
|
+
<m.div
|
|
126
|
+
key="file-drop-overlay"
|
|
127
|
+
aria-hidden
|
|
128
|
+
initial={{ opacity: 0 }}
|
|
129
|
+
animate={{ opacity: 1 }}
|
|
130
|
+
exit={{ opacity: 0, transition: { duration: 0.2 } }}
|
|
131
|
+
transition={{ duration: 0.15 }}
|
|
132
|
+
// Tidak menangkap pointer: dragenter/leave tetap dari elemen asli
|
|
133
|
+
// sehingga hitungan kedalaman tidak kacau oleh overlay sendiri.
|
|
134
|
+
// `fixed` + portal: menutupi seluruh viewport (sidebar ikut redup)
|
|
135
|
+
// dan kartu terpusat di tengah layar, bukan di tengah area chat.
|
|
136
|
+
className="pointer-events-none fixed inset-0 z-40 flex items-center justify-center bg-background/70 p-6 backdrop-blur-[2px]"
|
|
137
|
+
>
|
|
138
|
+
<m.div
|
|
139
|
+
ref={cardRef}
|
|
140
|
+
custom={dropped}
|
|
141
|
+
// Naik dari bawah layar lalu mengendap di tengah.
|
|
142
|
+
initial={{ opacity: 0, y: 120, scale: 0.96 }}
|
|
143
|
+
animate={{ opacity: 1, y: 0, scale: 1 }}
|
|
144
|
+
variants={{
|
|
145
|
+
// Dilepas: plate mengambil alih di posisi sama -> hilang instan.
|
|
146
|
+
// Batal (drag keluar jendela): turun kembali ke bawah.
|
|
147
|
+
exit: (wasDropped: boolean) =>
|
|
148
|
+
wasDropped
|
|
149
|
+
? { opacity: 0, transition: { duration: 0 } }
|
|
150
|
+
: {
|
|
151
|
+
opacity: 0,
|
|
152
|
+
y: 72,
|
|
153
|
+
scale: 0.97,
|
|
154
|
+
transition: { duration: 0.18, ease: "easeIn" },
|
|
155
|
+
},
|
|
156
|
+
}}
|
|
157
|
+
exit="exit"
|
|
158
|
+
transition={{
|
|
159
|
+
type: "spring",
|
|
160
|
+
stiffness: 340,
|
|
161
|
+
damping: 30,
|
|
162
|
+
mass: 0.9,
|
|
163
|
+
opacity: { duration: 0.18 },
|
|
164
|
+
}}
|
|
165
|
+
className={cn(
|
|
166
|
+
"flex aspect-[5/4] w-full max-w-md flex-col items-center justify-center gap-5 rounded-[28px] border bg-popover px-8 text-center shadow-2xl",
|
|
167
|
+
!accepting && "opacity-90",
|
|
168
|
+
)}
|
|
169
|
+
>
|
|
170
|
+
<FileStack muted={!accepting} />
|
|
171
|
+
<div className="flex flex-col gap-1.5">
|
|
172
|
+
<p className="text-xl font-semibold tracking-tight">
|
|
173
|
+
{accepting ? "Add anything" : "Can't add files right now"}
|
|
174
|
+
</p>
|
|
175
|
+
<p className="text-[14px] text-muted-foreground">
|
|
176
|
+
{accepting
|
|
177
|
+
? "Drop any file here to add it to the conversation"
|
|
178
|
+
: (blockedReason ?? "Try again when the session is ready.")}
|
|
179
|
+
</p>
|
|
180
|
+
</div>
|
|
181
|
+
{accepting && (
|
|
182
|
+
<span className="rounded-full bg-muted px-2.5 py-0.5 text-[12px] text-muted-foreground">
|
|
183
|
+
Up to 20 MB per file
|
|
184
|
+
</span>
|
|
185
|
+
)}
|
|
186
|
+
</m.div>
|
|
187
|
+
</m.div>
|
|
188
|
+
)}
|
|
189
|
+
</AnimatePresence>,
|
|
190
|
+
document.body,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Ilustrasi tiga kartu file (terminal, dokumen, gambar) yang bertumpuk. */
|
|
195
|
+
function FileStack({ muted }: { muted: boolean }) {
|
|
196
|
+
const spring = { type: "spring", stiffness: 380, damping: 22 } as const;
|
|
197
|
+
const tile =
|
|
198
|
+
"absolute flex size-14 items-center justify-center rounded-2xl shadow-lg ring-1 ring-black/5";
|
|
199
|
+
return (
|
|
200
|
+
<div className={cn("relative h-20 w-36", muted && "grayscale")}>
|
|
201
|
+
<m.span
|
|
202
|
+
initial={{ opacity: 0, x: 18, rotate: 0 }}
|
|
203
|
+
animate={{ opacity: 1, x: 0, rotate: -14 }}
|
|
204
|
+
transition={{ ...spring, delay: 0.05 }}
|
|
205
|
+
className={cn(tile, "top-2 left-3 bg-indigo-200 text-indigo-900")}
|
|
206
|
+
>
|
|
207
|
+
<SquareTerminalIcon className="size-7" strokeWidth={2.25} />
|
|
208
|
+
</m.span>
|
|
209
|
+
<m.span
|
|
210
|
+
initial={{ opacity: 0, x: -18, rotate: 0 }}
|
|
211
|
+
animate={{ opacity: 1, x: 0, rotate: 12 }}
|
|
212
|
+
transition={{ ...spring, delay: 0.08 }}
|
|
213
|
+
className={cn(tile, "top-0 right-3 bg-indigo-400 text-white")}
|
|
214
|
+
>
|
|
215
|
+
<FileTextIcon className="size-7" strokeWidth={2.25} />
|
|
216
|
+
</m.span>
|
|
217
|
+
<m.span
|
|
218
|
+
initial={{ opacity: 0, y: 14, scale: 0.9 }}
|
|
219
|
+
animate={{ opacity: 1, y: [0, -4, 0], scale: 1 }}
|
|
220
|
+
transition={{
|
|
221
|
+
opacity: { duration: 0.15 },
|
|
222
|
+
scale: spring,
|
|
223
|
+
// Melayang pelan selama drag berlangsung.
|
|
224
|
+
y: { duration: 2.4, repeat: Number.POSITIVE_INFINITY, ease: "easeInOut", delay: 0.3 },
|
|
225
|
+
}}
|
|
226
|
+
className={cn(tile, "top-5 left-1/2 -ml-7 size-16 bg-indigo-600 text-white")}
|
|
227
|
+
>
|
|
228
|
+
<ImageIcon className="size-8" strokeWidth={2.25} />
|
|
229
|
+
</m.span>
|
|
230
|
+
</div>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dropdown saran autocomplete `@file` (muncul di atas input saat token @
|
|
3
|
+
* aktif). Pemilihan lewat mousedown (bukan click) agar textarea tidak blur
|
|
4
|
+
* duluan — logika state autocomplete tetap di `useFileMention`.
|
|
5
|
+
*/
|
|
6
|
+
export interface FileMentionDropdownProps {
|
|
7
|
+
loading: boolean;
|
|
8
|
+
error: string | null;
|
|
9
|
+
suggestions: string[];
|
|
10
|
+
/** Indeks saran yang disorot (keyboard). */
|
|
11
|
+
highlighted: number;
|
|
12
|
+
/** Pilih saran ke-i (pemanggil menangani pemasukan teks + fokus). */
|
|
13
|
+
onPick: (index: number) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function FileMentionDropdown({
|
|
17
|
+
loading,
|
|
18
|
+
error,
|
|
19
|
+
suggestions,
|
|
20
|
+
highlighted,
|
|
21
|
+
onPick,
|
|
22
|
+
}: FileMentionDropdownProps) {
|
|
23
|
+
return (
|
|
24
|
+
<div className="absolute bottom-full left-0 right-0 z-10 mb-2 overflow-hidden rounded-xl border bg-popover shadow-md">
|
|
25
|
+
<div className="max-h-56 overflow-y-auto">
|
|
26
|
+
{loading && <div className="px-3 py-2 text-xs text-muted-foreground">Searching files…</div>}
|
|
27
|
+
{!loading && error && <div className="px-3 py-2 text-xs text-destructive">{error}</div>}
|
|
28
|
+
{suggestions.map((file, i) => (
|
|
29
|
+
<button
|
|
30
|
+
key={file}
|
|
31
|
+
type="button"
|
|
32
|
+
className={`block w-full truncate px-3 py-2 text-left font-mono text-base ${
|
|
33
|
+
i === highlighted ? "bg-accent text-accent-foreground" : ""
|
|
34
|
+
}`}
|
|
35
|
+
onMouseDown={(e) => {
|
|
36
|
+
// mousedown (bukan click) agar textarea tidak blur duluan.
|
|
37
|
+
e.preventDefault();
|
|
38
|
+
onPick(i);
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
{file}
|
|
42
|
+
</button>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightbox gambar — pratinjau layar penuh untuk lampiran (composer & pesan).
|
|
3
|
+
*
|
|
4
|
+
* - Dibangun di atas Radix Dialog (fokus terkunci, Esc menutup, klik latar
|
|
5
|
+
* menutup, `aria-modal`).
|
|
6
|
+
* - Beberapa gambar: tombol ‹ › + panah kiri/kanan keyboard, penghitung
|
|
7
|
+
* "2 / 3", dan geser (swipe) horizontal di layar sentuh.
|
|
8
|
+
* - Aksi: buka gambar asli di tab baru (hanya untuk URL non-blob — object URL
|
|
9
|
+
* lokal tidak berguna di tab lain).
|
|
10
|
+
*/
|
|
11
|
+
import { ChevronLeftIcon, ChevronRightIcon, ExternalLinkIcon, XIcon } from "lucide-react";
|
|
12
|
+
import { Dialog as DialogPrimitive } from "radix-ui";
|
|
13
|
+
import { useEffect, useRef } from "react";
|
|
14
|
+
import { cn } from "@/lib/utils";
|
|
15
|
+
|
|
16
|
+
export interface LightboxImage {
|
|
17
|
+
src: string;
|
|
18
|
+
alt: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ImageLightboxProps {
|
|
22
|
+
images: LightboxImage[];
|
|
23
|
+
/** Indeks gambar yang tampil; null = tertutup. */
|
|
24
|
+
index: number | null;
|
|
25
|
+
onIndexChange: (index: number | null) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Jarak geser minimum (px) agar dianggap swipe. */
|
|
29
|
+
const SWIPE_MIN = 50;
|
|
30
|
+
|
|
31
|
+
export function ImageLightbox({ images, index, onIndexChange }: ImageLightboxProps) {
|
|
32
|
+
const open = index !== null && images[index] !== undefined;
|
|
33
|
+
const current = open ? images[index] : undefined;
|
|
34
|
+
const many = images.length > 1;
|
|
35
|
+
const touchX = useRef<number | null>(null);
|
|
36
|
+
|
|
37
|
+
const go = (delta: number) => {
|
|
38
|
+
if (index === null || !many) return;
|
|
39
|
+
onIndexChange((index + delta + images.length) % images.length);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// Panah kiri/kanan untuk berpindah gambar.
|
|
43
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `go` dibuat ulang tiap render
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!open || !many) return;
|
|
46
|
+
const onKey = (e: KeyboardEvent) => {
|
|
47
|
+
if (e.key === "ArrowLeft") {
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
go(-1);
|
|
50
|
+
} else if (e.key === "ArrowRight") {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
go(1);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
window.addEventListener("keydown", onKey);
|
|
56
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
57
|
+
}, [open, many, index, images.length]);
|
|
58
|
+
|
|
59
|
+
const canOpenOriginal = current !== undefined && !current.src.startsWith("blob:");
|
|
60
|
+
const iconBtn =
|
|
61
|
+
"flex size-10 items-center justify-center rounded-full bg-white/10 text-white backdrop-blur transition-colors outline-none hover:bg-white/20 focus-visible:ring-[3px] focus-visible:ring-white/50";
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<DialogPrimitive.Root open={open} onOpenChange={(o) => !o && onIndexChange(null)}>
|
|
65
|
+
<DialogPrimitive.Portal>
|
|
66
|
+
<DialogPrimitive.Overlay className="fixed inset-0 z-[110] bg-black/90 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0" />
|
|
67
|
+
<DialogPrimitive.Content
|
|
68
|
+
aria-describedby={undefined}
|
|
69
|
+
className="fixed inset-0 z-[110] flex flex-col outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0"
|
|
70
|
+
onTouchStart={(e) => {
|
|
71
|
+
touchX.current = e.touches[0]?.clientX ?? null;
|
|
72
|
+
}}
|
|
73
|
+
onTouchEnd={(e) => {
|
|
74
|
+
const start = touchX.current;
|
|
75
|
+
const end = e.changedTouches[0]?.clientX;
|
|
76
|
+
touchX.current = null;
|
|
77
|
+
if (start === null || end === undefined) return;
|
|
78
|
+
if (Math.abs(end - start) >= SWIPE_MIN) go(end < start ? 1 : -1);
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
{/* Bar atas: nama file + penghitung, aksi di kanan. */}
|
|
82
|
+
<div className="flex shrink-0 items-center gap-3 px-3 pt-[max(0.75rem,env(safe-area-inset-top))] pb-2 text-white sm:px-4">
|
|
83
|
+
<DialogPrimitive.Title className="min-w-0 flex-1 truncate text-sm font-medium">
|
|
84
|
+
{current?.alt ?? "Image"}
|
|
85
|
+
{many && index !== null && (
|
|
86
|
+
<span className="ml-2 font-normal tabular-nums text-white/60">
|
|
87
|
+
{index + 1} / {images.length}
|
|
88
|
+
</span>
|
|
89
|
+
)}
|
|
90
|
+
</DialogPrimitive.Title>
|
|
91
|
+
{canOpenOriginal && current && (
|
|
92
|
+
<a
|
|
93
|
+
href={current.src}
|
|
94
|
+
target="_blank"
|
|
95
|
+
rel="noopener noreferrer"
|
|
96
|
+
aria-label="Open original in new tab"
|
|
97
|
+
title="Open original"
|
|
98
|
+
className={iconBtn}
|
|
99
|
+
>
|
|
100
|
+
<ExternalLinkIcon className="size-[18px]" aria-hidden />
|
|
101
|
+
</a>
|
|
102
|
+
)}
|
|
103
|
+
<DialogPrimitive.Close aria-label="Close preview" className={iconBtn}>
|
|
104
|
+
<XIcon className="size-5" aria-hidden />
|
|
105
|
+
</DialogPrimitive.Close>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
{/* Area gambar: klik di luar gambar menutup lightbox. */}
|
|
109
|
+
{/* biome-ignore lint/a11y/useKeyWithClickEvents: Esc & tombol Close menangani keyboard */}
|
|
110
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: klik latar = tutup (pola lightbox) */}
|
|
111
|
+
<div
|
|
112
|
+
className="relative flex min-h-0 flex-1 items-center justify-center px-3 pb-[max(1rem,env(safe-area-inset-bottom))] sm:px-16"
|
|
113
|
+
onClick={(e) => {
|
|
114
|
+
if (e.target === e.currentTarget) onIndexChange(null);
|
|
115
|
+
}}
|
|
116
|
+
>
|
|
117
|
+
{current && (
|
|
118
|
+
<img
|
|
119
|
+
key={current.src}
|
|
120
|
+
src={current.src}
|
|
121
|
+
alt={current.alt}
|
|
122
|
+
className="max-h-full max-w-full rounded-lg object-contain shadow-2xl animate-in fade-in-0 zoom-in-95 duration-200 select-none"
|
|
123
|
+
draggable={false}
|
|
124
|
+
/>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
{many && (
|
|
128
|
+
<>
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={() => go(-1)}
|
|
132
|
+
aria-label="Previous image"
|
|
133
|
+
className={cn(iconBtn, "absolute top-1/2 left-3 hidden -translate-y-1/2 sm:flex")}
|
|
134
|
+
>
|
|
135
|
+
<ChevronLeftIcon className="size-5" aria-hidden />
|
|
136
|
+
</button>
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
onClick={() => go(1)}
|
|
140
|
+
aria-label="Next image"
|
|
141
|
+
className={cn(
|
|
142
|
+
iconBtn,
|
|
143
|
+
"absolute top-1/2 right-3 hidden -translate-y-1/2 sm:flex",
|
|
144
|
+
)}
|
|
145
|
+
>
|
|
146
|
+
<ChevronRightIcon className="size-5" aria-hidden />
|
|
147
|
+
</button>
|
|
148
|
+
</>
|
|
149
|
+
)}
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
{/* Titik posisi di HP (tombol ‹ › disembunyikan; pakai swipe). */}
|
|
153
|
+
{many && index !== null && (
|
|
154
|
+
<div
|
|
155
|
+
className="flex shrink-0 justify-center gap-1.5 pb-[max(1rem,env(safe-area-inset-bottom))] sm:hidden"
|
|
156
|
+
aria-hidden
|
|
157
|
+
>
|
|
158
|
+
{images.map((img, i) => (
|
|
159
|
+
<span
|
|
160
|
+
key={img.src}
|
|
161
|
+
className={cn(
|
|
162
|
+
"size-1.5 rounded-full transition-colors",
|
|
163
|
+
i === index ? "bg-white" : "bg-white/30",
|
|
164
|
+
)}
|
|
165
|
+
/>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
</DialogPrimitive.Content>
|
|
170
|
+
</DialogPrimitive.Portal>
|
|
171
|
+
</DialogPrimitive.Root>
|
|
172
|
+
);
|
|
173
|
+
}
|