projects page re-design and a ton of other stuff

This commit is contained in:
Maze Winther
2026-01-26 16:10:16 +01:00
parent 954160d03b
commit 0c10bbf5ce
33 changed files with 1794 additions and 1185 deletions
@@ -44,6 +44,7 @@ import {
Video01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon, type IconSvgElement } from "@hugeicons/react";
import { OcVideoIcon } from "@opencut/ui/icons";
export function MediaView() {
const editor = useEditor();
@@ -558,19 +559,18 @@ function MediaPreview({
item: MediaAsset;
variant?: "grid" | "compact";
}) {
const shouldShowVideoOverlay = variant === "grid";
const shouldShowDurationBadge = variant === "grid";
if (item.type === "image") {
return (
<div className="flex size-full items-center justify-center">
<div className="relative flex size-full items-center justify-center">
<Image
src={item.url ?? ""}
alt={item.name}
className="max-h-full w-full object-cover"
fill
sizes="100vw"
className="object-cover"
loading="lazy"
width={item.width}
height={item.height}
unoptimized
/>
</div>
@@ -584,18 +584,12 @@ function MediaPreview({
<Image
src={item.thumbnailUrl}
alt={item.name}
className="size-full rounded object-cover"
fill
sizes="100vw"
className="rounded object-cover"
loading="lazy"
unoptimized
/>
{shouldShowVideoOverlay ? (
<div className="absolute inset-0 flex items-center justify-center rounded bg-black/20">
<HugeiconsIcon
icon={Video01Icon}
className="size-6 text-white drop-shadow-md"
/>
</div>
) : null}
{shouldShowDurationBadge ? (
<MediaDurationBadge duration={item.duration} />
) : null}
@@ -12,38 +12,43 @@ export function DeleteProjectDialog({
isOpen,
onOpenChange,
onConfirm,
projectName,
projectNames,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
projectName?: string;
projectNames: string[];
}) {
const count = projectNames.length;
const isSingle = count === 1;
const singleName = isSingle ? projectNames[0] : null;
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent
onOpenAutoFocus={(e) => {
e.preventDefault();
e.stopPropagation();
onOpenAutoFocus={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<DialogHeader>
<DialogTitle>
{projectName ? (
{singleName ? (
<>
{"Delete '"}
<span className="inline-block max-w-[300px] truncate align-bottom">
{projectName}
{singleName}
</span>
{"'?"}
</>
) : (
"Delete Project?"
`Delete ${count} projects?`
)}
</DialogTitle>
<DialogDescription>
Are you sure you want to delete this project? This action cannot be
undone.
{isSingle
? "Are you sure you want to delete this project? This action cannot be undone."
: `Are you sure you want to delete these ${count} projects? This action cannot be undone.`}
</DialogDescription>
</DialogHeader>
<DialogFooter>
@@ -24,7 +24,7 @@ import {
ArrowLeft02Icon,
Edit03Icon,
Delete02Icon,
Keyboard,
CommandIcon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { ShortcutsDialog } from "./shortcuts-dialog";
@@ -92,7 +92,9 @@ function ProjectDropdown() {
const handleDeleteProject = async () => {
if (activeProject) {
try {
await editor.project.deleteProject({ id: activeProject.metadata.id });
await editor.project.deleteProjects({
ids: [activeProject.metadata.id],
});
router.push("/projects");
} catch (error) {
toast.error("Failed to delete project", {
@@ -148,7 +150,7 @@ function ProjectDropdown() {
className="flex items-center gap-1.5"
onClick={() => setOpenDialog("shortcuts")}
>
<HugeiconsIcon icon={Keyboard} className="size-4" />
<HugeiconsIcon icon={CommandIcon} className="size-4" />
Keyboard shortcuts
</DropdownMenuItem>
<DropdownMenuItem asChild>
@@ -174,7 +176,7 @@ function ProjectDropdown() {
isOpen={openDialog === "delete"}
onOpenChange={(isOpen) => setOpenDialog(isOpen ? "delete" : null)}
onConfirm={handleDeleteProject}
projectName={activeProject?.metadata.name || ""}
projectNames={[activeProject?.metadata.name || ""]}
/>
<ShortcutsDialog
isOpen={openDialog === "shortcuts"}
+8 -10
View File
@@ -1,30 +1,28 @@
"use client";
import { ArrowRightIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import { SOCIAL_LINKS } from "@/constants/site-constants";
import { useLocalStorage } from "@/hooks/storage/use-local-storage";
import { Button } from "../ui/button";
import { Dialog, DialogContent, DialogTitle } from "../ui/dialog";
export function Onboarding() {
const [step, setStep] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const [hasSeenOnboarding, setHasSeenOnboarding] = useLocalStorage({
key: "hasSeenOnboarding",
defaultValue: false,
});
useEffect(() => {
const hasSeenOnboarding = localStorage.getItem("hasSeenOnboarding");
if (!hasSeenOnboarding) {
setIsOpen(true);
}
}, []);
const isOpen = !hasSeenOnboarding;
const handleNext = () => {
setStep(step + 1);
};
const handleClose = () => {
setIsOpen(false);
localStorage.setItem("hasSeenOnboarding", "true");
setHasSeenOnboarding({ value: true });
};
const getStepTitle = () => {
@@ -0,0 +1,74 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { TProjectMetadata } from "@/types/project";
import { formatDate } from "@/utils/date";
import { formatTimeCode } from "@/lib/time";
function InfoRow({
label,
value,
}: {
label: string;
value: string | React.ReactNode;
}) {
return (
<div className="flex justify-between items-center py-2 last:pb-0 border-b border-border/50 last:border-b-0">
<span className="text-muted-foreground text-sm">{label}</span>
<span className="text-sm font-medium">{value}</span>
</div>
);
}
export function ProjectInfoDialog({
isOpen,
onOpenChange,
project,
}: {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
project: TProjectMetadata;
}) {
const durationFormatted =
project.duration > 0
? formatTimeCode({
timeInSeconds: project.duration,
format: project.duration >= 3600 ? "HH:MM:SS" : "MM:SS",
})
: "0:00";
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle className="truncate max-w-[350px]">
{project.name}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col">
<InfoRow label="Duration" value={durationFormatted} />
<InfoRow
label="Created"
value={formatDate({ date: project.createdAt })}
/>
<InfoRow
label="Modified"
value={formatDate({ date: project.updatedAt })}
/>
<InfoRow
label="Project ID"
value={
<code className="text-xs bg-muted px-1.5 py-0.5 rounded">
{project.id.slice(0, 8)}
</code>
}
/>
</div>
</DialogContent>
</Dialog>
);
}
@@ -151,7 +151,7 @@ export function TextProperties({
<Textarea
placeholder="Name"
defaultValue={element.content}
className="bg-panel-accent min-h-18 resize-none"
className="bg-panel-accent min-h-20"
onChange={(e) =>
editor.timeline.updateTextElement({
trackId,
+1 -1
View File
@@ -65,7 +65,7 @@ const AlertDialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-4",
className,
)}
{...props}
+14 -10
View File
@@ -12,25 +12,28 @@ const buttonVariants = cva(
default:
"bg-foreground text-background shadow-sm hover:bg-foreground/90",
foreground:
"bg-background text-foreground shadow-sm hover:bg-background/80",
"bg-background text-foreground",
primary:
"bg-primary text-primary-foreground shadow-sm hover:bg-primary/90",
"bg-primary text-primary-foreground hover:bg-primary/90",
"primary-gradient":
"bg-gradient-to-r from-cyan-400 to-blue-500 text-white hover:opacity-85 transition-opacity",
destructive:
"bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/80",
"bg-destructive text-destructive-foreground hover:bg-destructive/80",
"destructive-foreground":
"border bg-background hover:bg-destructive/15 text-destructive",
outline:
"border border-input bg-transparent shadow-xs transition-colors hover:bg-accent",
"border border-border bg-transparent transition-colors hover:bg-accent",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-foreground/15 border border-input",
text: "bg-transparent p-0 rounded-none opacity-100 hover:opacity-50 transition-opacity", // Instead of ghost (matches app better)
link: "text-primary underline-offset-4 hover:underline",
"bg-secondary text-secondary-foreground hover:bg-foreground/15 border border-input",
text: "bg-transparent rounded-none !opacity-100",
link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-full px-3 text-xs",
lg: "h-10 rounded-full p-5",
icon: "size-7",
lg: "h-10 rounded-full p-5 px-6",
icon: "size-7 rounded-sm",
text: "p-0",
},
},
defaultVariants: {
@@ -49,9 +52,10 @@ export interface ButtonProps
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? SlotPrimitive.Slot : "button";
const effectiveSize = size ?? (variant === "text" ? "text" : "default");
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
className={cn(buttonVariants({ variant, size: effectiveSize, className }))}
ref={ref}
{...props}
/>
+1 -1
View File
@@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer border-primary focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground size-4 shrink-0 rounded-sm border shadow-sm focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
"cursor-pointer bg-background peer focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary size-4 shrink-0 shadow-xs rounded-[0.35rem] border focus-visible:ring-1 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
+1 -1
View File
@@ -75,7 +75,7 @@ const DialogFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-4",
className,
)}
{...props}
+3 -3
View File
@@ -21,16 +21,16 @@ export function FontPicker({
return (
<Select defaultValue={defaultValue} onValueChange={onValueChange}>
<SelectTrigger
className={`bg-panel-accent h-8 w-full text-xs ${className || ""}`}
className={`bg-panel-accent h-9 w-full text-base ${className || ""}`}
>
<SelectValue placeholder="Select a font" />
<SelectValue placeholder="Select a font" className="text-sm" />
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((font) => (
<SelectItem
key={font.value}
value={font.value}
className="text-xs"
className="text-sn"
style={{ fontFamily: font.value }}
>
{font.label}
+26 -6
View File
@@ -1,12 +1,32 @@
"use client";
import { Eye, EyeOff, X } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/utils/ui";
import { Button } from "./button";
import { forwardRef, type ComponentProps } from "react";
import { useState } from "react";
interface InputProps extends ComponentProps<"input"> {
const inputVariants = cva(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-border bg-background flex w-full min-w-0 rounded-full border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-primary/10 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
size: {
default:
"h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm",
sm: "h-8 px-3 text-xs file:h-6 file:text-xs",
lg: "h-10 px-4 text-base file:h-8 file:text-sm md:text-sm",
},
},
defaultVariants: {
size: "default",
},
},
);
interface InputProps
extends Omit<ComponentProps<"input">, "size">,
VariantProps<typeof inputVariants> {
showPassword?: boolean;
onShowPasswordChange?: (show: boolean) => void;
showClearIcon?: boolean;
@@ -19,6 +39,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
{
className,
type,
size,
containerClassName,
showPassword,
onShowPasswordChange,
@@ -55,11 +76,10 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
<input
type={inputType}
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground border-input bg-background flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
paddingRight,
className,
inputVariants({
size,
className: cn(paddingRight, className),
}),
)}
ref={ref}
value={value}
+11 -10
View File
@@ -14,12 +14,12 @@ const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const selectItemVariants = cva(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-hidden transition-opacity data-disabled:pointer-events-none data-disabled:opacity-50",
"relative flex cursor-pointer select-none items-center gap-2 rounded-xl px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-accent/35 data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "focus:opacity-65 focus:text-accent-foreground",
destructive: "text-destructive focus:text-destructive/80",
default: "",
destructive: "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive",
},
},
defaultVariants: {
@@ -36,6 +36,7 @@ const SelectTrigger = React.forwardRef<
ref={ref}
className={cn(
"bg-accent ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-7 w-auto cursor-pointer items-center justify-between gap-1 rounded-md px-3 py-2 text-sm whitespace-nowrap focus:ring-1 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
"focus:border-primary focus:ring-4 focus:ring-primary/10 border-transparent transition-none",
className,
)}
{...props}
@@ -91,9 +92,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
"bg-popover text-popover-foreground z-50 max-h-96 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg",
className,
)}
position={position}
@@ -106,7 +105,6 @@ const SelectContent = React.forwardRef<
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)",
)}
@@ -125,7 +123,10 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
className={cn(
"px-3 pb-2 pt-1 text-[11px] font-bold uppercase tracking-wider text-muted-foreground",
className,
)}
{...props}
/>
));
@@ -139,7 +140,7 @@ const SelectItem = React.forwardRef<
>(({ className, children, variant = "default", ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(selectItemVariants({ variant }), className)}
className={cn(selectItemVariants({ variant }), "pr-8 pl-2", className)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
@@ -158,7 +159,7 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("bg-foreground/10 -mx-1 my-1 h-px", className)}
className={cn("bg-border/60 mx-1 my-2 h-px", className)}
{...props}
/>
));
+1 -1
View File
@@ -98,7 +98,7 @@ const SheetFooter = ({
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-4",
className,
)}
{...props}
+2 -2
View File
@@ -9,8 +9,8 @@ const Textarea = React.forwardRef<
return (
<textarea
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent/50 flex min-h-[60px] w-full rounded-md border px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[2px]",
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent/50 flex min-h-[60px] w-full rounded-md border px-3 py-2 text-base shadow-xs outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-primary/10",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
)}