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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { Modal } from "./modal"
|
||||
export { ConfirmDialog } from "./confirm-dialog"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user