This commit is contained in:
Maze Winther
2026-01-15 11:45:53 +01:00
parent deef784b76
commit dba74fa20f
6 changed files with 112 additions and 88 deletions
@@ -1,7 +1,3 @@
{ {
"/_not-found/page": "app/_not-found/page.js",
"/api/sounds/search/route": "app/api/sounds/search/route.js",
"/editor/[project_id]/page": "app/editor/[project_id]/page.js",
"/page": "app/page.js",
"/projects/page": "app/projects/page.js" "/projects/page": "app/projects/page.js"
} }
File diff suppressed because one or more lines are too long
+91 -81
View File
@@ -15,7 +15,7 @@ import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import type { KeyboardEvent, MouseEvent } from "react"; import type { KeyboardEvent, MouseEvent } from "react";
import React, { useState } from "react"; import { useState } from "react";
import { DeleteProjectDialog } from "@/components/delete-project-dialog"; import { DeleteProjectDialog } from "@/components/delete-project-dialog";
import { RenameProjectDialog } from "@/components/rename-project-dialog"; import { RenameProjectDialog } from "@/components/rename-project-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -39,6 +39,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import type { TProjectMetadata } from "@/types/project"; import type { TProjectMetadata } from "@/types/project";
import { toast } from "sonner"; import { toast } from "sonner";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { formatDate } from "@/lib/utils";
export default function ProjectsPage() { export default function ProjectsPage() {
const [isSelectionMode, setIsSelectionMode] = useState(false); const [isSelectionMode, setIsSelectionMode] = useState(false);
@@ -51,8 +52,6 @@ export default function ProjectsPage() {
const router = useRouter(); const router = useRouter();
const editor = useEditor(); const editor = useEditor();
const hasSearchQuery = searchQuery.trim().length > 0;
const handleCreateProject = async () => { const handleCreateProject = async () => {
try { try {
const projectId = await editor.project.createNewProject({ const projectId = await editor.project.createNewProject({
@@ -98,7 +97,9 @@ export default function ProjectsPage() {
const handleSelectAll = ({ checked }: { checked: boolean }) => { const handleSelectAll = ({ checked }: { checked: boolean }) => {
if (checked) { if (checked) {
setSelectedProjects(new Set(sortedProjects.map((project) => project.id))); setSelectedProjects(
new Set(projectsToDisplay.map((project) => project.id)),
);
} else { } else {
setSelectedProjects(new Set()); setSelectedProjects(new Set());
} }
@@ -128,16 +129,18 @@ export default function ProjectsPage() {
} }
}; };
const sortedProjects = editor.project.getFilteredAndSortedProjects({ const projectsToDisplay = editor.project.getFilteredAndSortedProjects({
searchQuery, searchQuery,
sortOption, sortOption,
}); });
const isAllSelected = const isAllSelected =
sortedProjects.length > 0 && projectsToDisplay.length > 0 &&
selectedProjects.size === sortedProjects.length; selectedProjects.size === projectsToDisplay.length;
const hasSomeSelected = const hasSomeSelected =
selectedProjects.size > 0 && selectedProjects.size < sortedProjects.length; selectedProjects.size > 0 &&
selectedProjects.size < projectsToDisplay.length;
const isLoading = editor.project.getIsLoading(); const isLoading = editor.project.getIsLoading();
const isInitialized = editor.project.getIsInitialized(); const isInitialized = editor.project.getIsInitialized();
@@ -186,8 +189,8 @@ export default function ProjectsPage() {
Your projects Your projects
</h1> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{sortedProjects.length}{" "} {projectsToDisplay.length}{" "}
{sortedProjects.length === 1 ? "project" : "projects"} {projectsToDisplay.length === 1 ? "project" : "projects"}
{isSelectionMode && selectedProjects.size > 0 && ( {isSelectionMode && selectedProjects.size > 0 && (
<span className="text-primary ml-2"> <span className="text-primary ml-2">
{selectedProjects.size} selected {selectedProjects.size} selected
@@ -217,7 +220,7 @@ export default function ProjectsPage() {
<Button <Button
variant="outline" variant="outline"
onClick={() => setIsSelectionMode(true)} onClick={() => setIsSelectionMode(true)}
disabled={sortedProjects.length === 0} disabled={projectsToDisplay.length === 0}
> >
Select projects Select projects
</Button> </Button>
@@ -286,7 +289,7 @@ export default function ProjectsPage() {
</div> </div>
</div> </div>
{isSelectionMode && sortedProjects.length > 0 && ( {isSelectionMode && projectsToDisplay.length > 0 && (
<button <button
type="button" type="button"
onClick={() => handleSelectAll({ checked: !isAllSelected })} onClick={() => handleSelectAll({ checked: !isAllSelected })}
@@ -306,39 +309,24 @@ export default function ProjectsPage() {
{isAllSelected ? "Deselect all" : "Select all"} {isAllSelected ? "Deselect all" : "Select all"}
</span> </span>
<span className="text-muted-foreground text-sm"> <span className="text-muted-foreground text-sm">
({selectedProjects.size} of {sortedProjects.length} selected) ({selectedProjects.size} of {projectsToDisplay.length} selected)
</span> </span>
</button> </button>
)} )}
{isLoading || !isInitialized ? ( {isLoading || !isInitialized ? (
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4"> <ProjectsLoader />
{Array.from({ length: 8 }, (_, index) => ( ) : projectsToDisplay.length === 0 ? (
<div <EmptyState
key={`skeleton-${index}`} search={{
className="bg-background overflow-hidden border-none p-0" query: searchQuery,
> onClearSearch: () => setSearchQuery(""),
<Skeleton className="bg-muted/50 aspect-square w-full" /> }}
<div className="flex flex-col gap-1.5 px-0 pt-5"> onCreateProject={handleCreateProject}
<Skeleton className="bg-muted/50 h-4 w-3/4" />
<div className="flex items-center gap-1.5">
<Skeleton className="bg-muted/50 h-4 w-4" />
<Skeleton className="bg-muted/50 h-4 w-24" />
</div>
</div>
</div>
))}
</div>
) : sortedProjects.length === 0 && hasSearchQuery ? (
<NoResults
searchQuery={searchQuery}
onClearSearch={() => setSearchQuery("")}
/> />
) : sortedProjects.length === 0 ? (
<NoProjects onCreateProject={handleCreateProject} />
) : ( ) : (
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4"> <div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{sortedProjects.map((project) => ( {projectsToDisplay.map((project) => (
<ProjectCard <ProjectCard
key={project.id} key={project.id}
project={project} project={project}
@@ -384,14 +372,6 @@ function ProjectCard({
const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false); const [isRenameDialogOpen, setIsRenameDialogOpen] = useState(false);
const editor = useEditor(); const editor = useEditor();
const formatDate = ({ date }: { date: Date }): string => {
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
const handleDeleteProject = async () => { const handleDeleteProject = async () => {
await editor.project.deleteProject({ id: project.id }); await editor.project.deleteProject({ id: project.id });
setIsDropdownOpen(false); setIsDropdownOpen(false);
@@ -407,19 +387,24 @@ function ProjectCard({
await editor.project.duplicateProject({ id: project.id }); await editor.project.duplicateProject({ id: project.id });
}; };
const handleCardClick = ({e}: {}) => { const handleCardClick = ({
event,
}: {
event: MouseEvent<HTMLButtonElement>;
}) => {
if (isSelectionMode) { if (isSelectionMode) {
e.preventDefault(); event.preventDefault();
onSelect?.({ projectId: project.id, checked: !isSelected }); onSelect?.({ projectId: project.id, checked: !isSelected });
} }
}; };
const handleCardKeyDown = ({ const handleCardKeyDown = ({
key, event,
preventDefault, }: {
}: KeyboardEvent<HTMLButtonElement>) => { event: KeyboardEvent<HTMLButtonElement>;
if (isSelectionMode && (key === "Enter" || key === " ")) { }) => {
preventDefault(); if (isSelectionMode && (event.key === "Enter" || event.key === " ")) {
event.preventDefault();
onSelect?.({ projectId: project.id, checked: !isSelected }); onSelect?.({ projectId: project.id, checked: !isSelected });
} }
}; };
@@ -552,8 +537,8 @@ function ProjectCard({
{isSelectionMode ? ( {isSelectionMode ? (
<button <button
type="button" type="button"
onClick={handleCardClick} onClick={(event) => handleCardClick({ event })}
onKeyDown={handleCardKeyDown} onKeyDown={(event) => handleCardKeyDown({ event })}
className="group block w-full cursor-pointer text-left" className="group block w-full cursor-pointer text-left"
> >
{cardContent} {cardContent}
@@ -578,6 +563,28 @@ function ProjectCard({
); );
} }
function ProjectsLoader() {
return (
<div className="xs:grid-cols-2 grid grid-cols-1 gap-6 sm:grid-cols-3 lg:grid-cols-4">
{Array.from({ length: 8 }, (_, index) => (
<div
key={`skeleton-${index}`}
className="bg-background overflow-hidden border-none p-0"
>
<Skeleton className="bg-muted/50 aspect-square w-full" />
<div className="flex flex-col gap-1.5 px-0 pt-5">
<Skeleton className="bg-muted/50 h-4 w-3/4" />
<div className="flex items-center gap-1.5">
<Skeleton className="bg-muted/50 h-4 w-4" />
<Skeleton className="bg-muted/50 h-4 w-24" />
</div>
</div>
</div>
))}
</div>
);
}
function CreateButton({ onClick }: { onClick?: () => void }) { function CreateButton({ onClick }: { onClick?: () => void }) {
return ( return (
<Button className="flex" onClick={onClick}> <Button className="flex" onClick={onClick}>
@@ -587,7 +594,35 @@ function CreateButton({ onClick }: { onClick?: () => void }) {
); );
} }
function NoProjects({ onCreateProject }: { onCreateProject: () => void }) { function EmptyState({
search,
onCreateProject,
}: {
search: { query: string; onClearSearch: () => void };
onCreateProject: () => void;
}) {
const editor = useEditor();
const savedProjects = editor.project.getSavedProjects();
if (savedProjects.length > 0) {
return (
<div className="flex flex-col items-center justify-center gap-6 py-16 text-center">
<div className="flex flex-col items-center gap-2">
<div className="bg-muted/30 flex size-16 items-center justify-center rounded-full">
<Search className="text-muted-foreground size-8" />
</div>
<h3 className="text-lg font-medium">No results found</h3>
<p className="text-muted-foreground max-w-md">
Your search for "{search.query}" did not return any results.
</p>
</div>
<Button onClick={search.onClearSearch} variant="outline">
Clear search
</Button>
</div>
);
}
return ( return (
<div className="flex flex-col items-center justify-center gap-6 py-16 text-center"> <div className="flex flex-col items-center justify-center gap-6 py-16 text-center">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
@@ -601,34 +636,9 @@ function NoProjects({ onCreateProject }: { onCreateProject: () => void }) {
</p> </p>
</div> </div>
<Button size="lg" className="gap-2" onClick={onCreateProject}> <Button size="lg" className="gap-2" onClick={onCreateProject}>
<Plus className="h-4 w-4" /> <Plus className="size-4" />
Create your first project Create your first project
</Button> </Button>
</div> </div>
); );
} }
function NoResults({
searchQuery,
onClearSearch,
}: {
searchQuery: string;
onClearSearch: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center gap-6 py-16 text-center">
<div className="flex flex-col items-center gap-2">
<div className="bg-muted/30 flex size-16 items-center justify-center rounded-full">
<Search className="text-muted-foreground size-8" />
</div>
<h3 className="text-lg font-medium">No results found</h3>
<p className="text-muted-foreground max-w-md">
Your search for "{searchQuery}" did not return any results.
</p>
</div>
<Button onClick={onClearSearch} variant="outline">
Clear search
</Button>
</div>
);
}
@@ -29,7 +29,6 @@ export function useTimelineZoom({
const isHorizontalScrollGesture = const isHorizontalScrollGesture =
event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY); event.shiftKey || Math.abs(event.deltaX) > Math.abs(event.deltaY);
// allow scrollarea to handle horizontal scroll
if (isHorizontalScrollGesture) { if (isHorizontalScrollGesture) {
return; return;
} }
+13 -1
View File
@@ -5,6 +5,14 @@ export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
} }
export function formatDate({ date }: { date: Date }): string {
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
export function capitalizeFirstLetter({ string }: { string: string }) { export function capitalizeFirstLetter({ string }: { string: string }) {
return string.charAt(0).toUpperCase() + string.slice(1); return string.charAt(0).toUpperCase() + string.slice(1);
} }
@@ -38,7 +46,11 @@ export function generateUUID(): string {
); );
} }
export function isTypableDOMElement({ element }: { element: HTMLElement }): boolean { export function isTypableDOMElement({
element,
}: {
element: HTMLElement;
}): boolean {
if (element.isContentEditable) return true; if (element.isContentEditable) return true;
if (element.tagName === "INPUT") { if (element.tagName === "INPUT") {
+1 -1
View File
@@ -40,7 +40,7 @@ Review every point below carefully for files to ensure they follow consistent co
``` ```
18. Components render UI. Domain logic (data transformations, business rules, state mutations) lives in hooks, utilities, or managers. Simple interaction logic (gesture detection, modifier keys) can stay in components if not too many lines of code/complex. 18. Components render UI. Domain logic (data transformations, business rules, state mutations) lives in hooks, utilities, or managers. Simple interaction logic (gesture detection, modifier keys) can stay in components if not too many lines of code/complex.
19. Readability matters more than short names in the age of AI coding. Do not shorten names for the sake of "typing less". Nobody is typing code these days. AI agents are. Hence, clarity and readability are the most important things. Example: "event" is better than "e". "element" is better than "el". 19. Readability matters more than short names in the age of AI coding. Do not shorten names for the sake of "typing less". Nobody is typing code these days. AI agents are. Hence, clarity and readability are the most important things. Example: "element" is better than "el".
# Functions # Functions