refactor: convert to bun workspaces monorepo

- Move web app into apps/web/
- Create packages/shared/ with shared types
- Create plugins/decky-vault/ scaffold
- Root package.json manages workspaces only
This commit is contained in:
2026-06-28 05:20:28 +08:00
parent c4bede20d4
commit cd72b7a948
345 changed files with 488 additions and 126 deletions
@@ -0,0 +1,79 @@
"use client"
import { Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { Modal } from "./modal"
export interface ConfirmDialogProps {
open: boolean
onClose: () => void
onConfirm: () => void
title?: string
message?: string
confirmLabel?: string
cancelLabel?: string
variant?: "default" | "destructive"
loading?: boolean
children?: React.ReactNode
}
export function ConfirmDialog({
open,
onClose,
onConfirm,
title = "Confirm",
message,
confirmLabel = "Confirm",
cancelLabel = "Cancel",
variant = "default",
loading = false,
children,
}: ConfirmDialogProps) {
return (
<Modal
isOpen={open}
onClose={loading ? () => {} : onClose}
title={title}
size="sm"
showCloseButton={!loading}
>
<div className="space-y-4">
{message && (
<p className="text-sm text-text/80">{message}</p>
)}
{children}
<div className="flex items-center justify-end gap-3">
<button
type="button"
onClick={onClose}
disabled={loading}
className={cn(
"inline-flex touch-target items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50",
"bg-text/10 text-text hover:bg-text/20",
loading && "opacity-70 cursor-not-allowed"
)}
>
{cancelLabel}
</button>
<button
type="button"
onClick={onConfirm}
disabled={loading}
className={cn(
"inline-flex touch-target items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50",
variant === "destructive"
? "bg-red-600 text-white hover:bg-red-700"
: "bg-primary text-white hover:bg-primary/90",
loading && "opacity-70 cursor-not-allowed"
)}
>
{loading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{confirmLabel}
</button>
</div>
</div>
</Modal>
)
}
+2
View File
@@ -0,0 +1,2 @@
export { Modal } from "./modal"
export { ConfirmDialog } from "./confirm-dialog"
+184
View File
@@ -0,0 +1,184 @@
"use client"
import { useCallback, useEffect, useRef, useLayoutEffect } from "react"
import { createPortal } from "react-dom"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const sizeClasses = {
sm: "max-w-sm",
md: "max-w-md",
lg: "max-w-lg",
xl: "max-w-xl",
full: "max-w-full w-full h-full m-0 rounded-none",
}
type ModalSize = keyof typeof sizeClasses
export interface ModalProps {
isOpen: boolean
onClose: () => void
title?: string
description?: string
size?: ModalSize
children?: React.ReactNode
className?: string
showCloseButton?: boolean
}
// Use useLayoutEffect for DOM reads/writes to avoid hydration mismatches
const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect
export function Modal({
isOpen,
onClose,
title,
description,
size = "md",
children,
className,
showCloseButton = true,
}: ModalProps) {
const modalRef = useRef<HTMLDivElement>(null)
const previousActiveElement = useRef<HTMLElement | null>(null)
// Escape handler
const handleEscape = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose()
}
},
[onClose],
)
useEffect(() => {
if (!isOpen) return
document.addEventListener("keydown", handleEscape)
return () => document.removeEventListener("keydown", handleEscape)
}, [isOpen, handleEscape])
// Body scroll lock
useEffect(() => {
if (isOpen) {
const originalOverflow = document.body.style.overflow
document.body.style.overflow = "hidden"
return () => {
document.body.style.overflow = originalOverflow
}
}
}, [isOpen])
// Focus management: store previously focused element and restore on close
useIsomorphicLayoutEffect(() => {
if (isOpen) {
previousActiveElement.current = document.activeElement as HTMLElement
// Focus the modal header or the close button for accessibility
const firstFocusable = modalRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
)
firstFocusable?.focus()
return () => {
previousActiveElement.current?.focus()
}
}
}, [isOpen])
// Focus trap
useEffect(() => {
if (!isOpen) return
const handleTab = (e: KeyboardEvent) => {
if (e.key !== "Tab" || !modalRef.current) return
const focusableElements = modalRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
)
const first = focusableElements[0]
const last = focusableElements[focusableElements.length - 1]
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault()
last?.focus()
}
} else {
if (document.activeElement === last) {
e.preventDefault()
first?.focus()
}
}
}
document.addEventListener("keydown", handleTab)
return () => document.removeEventListener("keydown", handleTab)
}, [isOpen])
if (!isOpen) return null
return createPortal(
<div className="fixed inset-0 z-50" aria-hidden={!isOpen}>
{/* Overlay */}
<div
className="absolute inset-0 bg-black/50"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal wrapper */}
<div className="absolute inset-0 flex items-center justify-center overflow-y-auto p-4">
<div
ref={modalRef}
role="dialog"
aria-modal="true"
aria-labelledby={title ? "modal-title" : undefined}
aria-describedby={description ? "modal-description" : undefined}
className={cn(
"relative w-full rounded-xl border border-border bg-background text-text shadow-xl",
size !== "full" && "my-auto max-h-[90vh] flex flex-col",
size === "full" && "h-full flex flex-col",
sizeClasses[size],
className,
)}
>
{/* Header */}
{(title || showCloseButton) && (
<div className="flex items-center justify-between gap-4 border-b border-border px-5 py-4 shrink-0">
<div className="flex-1 min-w-0">
{title && (
<h2
id="modal-title"
className="text-base font-semibold text-text truncate"
>
{title}
</h2>
)}
{description && (
<p id="modal-description" className="mt-0.5 text-sm text-text/60">
{description}
</p>
)}
</div>
{showCloseButton && (
<button
type="button"
onClick={onClose}
className="shrink-0 rounded-md p-1.5 text-text/50 transition-colors hover:bg-text/5 hover:text-text focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50 cursor-pointer"
aria-label="Close modal"
>
<X className="h-4 w-4" />
</button>
)}
</div>
)}
{/* Body */}
<div className={cn("flex-1 overflow-y-auto", !title && !showCloseButton && "p-5", title || showCloseButton ? "px-5 py-4" : "")}>
{children}
</div>
</div>
</div>
</div>,
document.body,
)
}
@@ -0,0 +1,127 @@
// components/ui/screenshot-lightbox.tsx
"use client"
import { useState, useCallback, useEffect } from "react"
import { AnimatePresence, motion } from "motion/react"
import { XIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
interface ScreenshotLightboxProps {
screenshots: Array<{ id: string; url: string; width: number; height: number }>
initialIndex: number
onClose: () => void
}
export function ScreenshotLightbox({
screenshots,
initialIndex,
onClose,
}: ScreenshotLightboxProps) {
const [currentIndex, setCurrentIndex] = useState(initialIndex)
const [zoomed, setZoomed] = useState(false)
const hasMultiple = screenshots.length > 1
const goNext = useCallback(() => {
setZoomed(false)
setCurrentIndex((prev) => (prev < screenshots.length - 1 ? prev + 1 : prev))
}, [screenshots.length])
const goPrev = useCallback(() => {
setZoomed(false)
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : prev))
}, [])
const toggleZoom = useCallback(() => {
setZoomed((z) => !z)
}, [])
// Keyboard handlers
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose()
} else if (e.key === "ArrowRight" && hasMultiple) {
goNext()
} else if (e.key === "ArrowLeft" && hasMultiple) {
goPrev()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [onClose, goNext, goPrev, hasMultiple])
const current = screenshots[currentIndex]
if (!current) return null
return (
<AnimatePresence>
<motion.div
key="lightbox-backdrop"
className="fixed inset-0 z-[60] bg-black/90 flex items-center justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={(e) => {
if (e.target === e.currentTarget) onClose()
}}
>
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors z-10 cursor-pointer"
aria-label="Close"
>
<XIcon className="h-5 w-5 text-white" />
</button>
{/* Previous arrow */}
{hasMultiple && currentIndex > 0 && (
<button
onClick={goPrev}
className="absolute left-4 top-1/2 -translate-y-1/2 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors z-10 cursor-pointer"
aria-label="Previous screenshot"
>
<ChevronLeftIcon className="h-6 w-6 text-white" />
</button>
)}
{/* Next arrow */}
{hasMultiple && currentIndex < screenshots.length - 1 && (
<button
onClick={goNext}
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 rounded-full bg-white/10 hover:bg-white/20 transition-colors z-10 cursor-pointer"
aria-label="Next screenshot"
>
<ChevronRightIcon className="h-6 w-6 text-white" />
</button>
)}
{/* Image container — scrollable for panning when zoomed */}
<div
className="flex items-center justify-center w-full h-full overflow-auto"
style={{ touchAction: zoomed ? "pan-x pan-y" : "pinch-zoom" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={current.url}
alt={`Screenshot ${currentIndex + 1}`}
className={`transition-transform duration-200 ${
zoomed
? "max-w-none cursor-zoom-out"
: "max-w-[90vw] max-h-[90vh] object-contain cursor-zoom-in"
}`}
style={zoomed ? { width: current.width, height: current.height } : undefined}
onClick={toggleZoom}
draggable={false}
/>
</div>
{/* Counter */}
{hasMultiple && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-white/10 text-white text-xs font-medium">
{currentIndex + 1} / {screenshots.length}
</div>
)}
</motion.div>
</AnimatePresence>
)
}