This commit is contained in:
Maze Winther 2025-07-17 17:19:23 +02:00
commit ce47e88e34
6 changed files with 196 additions and 44 deletions

View File

@ -4,11 +4,35 @@ Thank you for your interest in contributing to OpenCut! This document provides g
## Getting Started
### Prerequisites
- [Node.js](https://nodejs.org/en/) (v18 or later)
- [Bun](https://bun.sh/docs/installation)
(for `npm` alternative)
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
> **Note:** Docker is optional, but it's essential for running the local database and Redis services. If you're planning to contribute to frontend features, you can skip the Docker setup. If you have followed the steps below in [Setup](#setup), you're all set to go!
### Setup
1. Fork the repository
2. Clone your fork locally
3. Navigate to the web app directory: `cd apps/web`
4. Install dependencies: `bun install`
5. Start the development server: `bun run dev`
4. Copy `.env.example` to `.env.local`:
```bash
# Unix/Linux/Mac
cp .env.example .env.local
# Windows Command Prompt
copy .env.example .env.local
# Windows PowerShell
Copy-Item .env.example .env.local
```
5. Install dependencies: `bun install`
6. Start the development server: `bun run dev`
> **Note:** If you see an error like `Unsupported URL Type "workspace:*"` when running `npm install`, you have two options:
>
@ -18,6 +42,7 @@ Thank you for your interest in contributing to OpenCut! This document provides g
## What to Focus On
**🎯 Good Areas to Contribute:**
- Timeline functionality and UI improvements
- Project management features
- Performance optimizations
@ -26,6 +51,7 @@ Thank you for your interest in contributing to OpenCut! This document provides g
- Documentation and testing
**⚠️ Areas to Avoid:**
- Preview panel enhancements (text fonts, stickers, effects)
- Export functionality improvements
- Preview rendering optimizations
@ -38,12 +64,6 @@ If you're unsure whether your idea falls into the preview category, feel free to
## Development Setup
### Prerequisites
- Node.js 18+
- Bun (latest version)
- Docker (for local database)
### Local Development
1. Start the database and Redis services:

3
.gitignore vendored
View File

@ -9,8 +9,6 @@
# debug
/apps/web/npm-debug.log*
/apps/web/yarn-debug.log*
/apps/web/yarn-error.log*
# env files (can opt-in for committing if needed)
/apps/web/.env*
@ -18,7 +16,6 @@
# typescript
/apps/web/next-env.d.ts
/apps/web/yarn.lock
# asdf version management
.tool-versions

View File

@ -39,26 +39,36 @@
Before you begin, ensure you have the following installed on your system:
- [Node.js](https://nodejs.org/en/) (v18 or later)
- [Bun](https://bun.sh/docs/installation)
(for `npm` alternative)
- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/)
- [Node.js](https://nodejs.org/en/) (for `npm` alternative)
> **Note:** Docker is optional, but it's essential for running the local database and Redis services. If you're planning to run the frontend or want to contribute to frontend features, you can skip the Docker setup. If you have followed the steps below in [Setup](#setup), you're all set to go!
### Setup
1. Fork the repository
2. Clone your fork locally
3. Navigate to the web app directory: `cd apps/web`
4. Install dependencies: `bun install`
5. Start the development server: `bun dev`
4. Copy `.env.example` to `.env.local`:
```bash
# Unix/Linux/Mac
cp .env.example .env.local
# Windows Command Prompt
copy .env.example .env.local
# Windows PowerShell
Copy-Item .env.example .env.local
```
5. Install dependencies: `bun install`
6. Start the development server: `bun dev`
## Development Setup
### Prerequisites
- Node.js 18+
- Bun (latest version)
- Docker (for local database)
### Local Development
1. Start the database and Redis services:

View File

@ -5,6 +5,7 @@ import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import {
ChevronLeft,
Plus,
@ -14,6 +15,7 @@ import {
Loader2,
X,
Trash2,
Search,
} from "lucide-react";
import { TProject } from "@/types/project";
import Image from "next/image";
@ -28,6 +30,13 @@ import { useProjectStore } from "@/stores/project-store";
import { useRouter } from "next/navigation";
import { DeleteProjectDialog } from "@/components/delete-project-dialog";
import { RenameProjectDialog } from "@/components/rename-project-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export default function ProjectsPage() {
const {
@ -36,6 +45,7 @@ export default function ProjectsPage() {
isLoading,
isInitialized,
deleteProject,
getFilteredAndSortedProjects,
} = useProjectStore();
const router = useRouter();
const [isSelectionMode, setIsSelectionMode] = useState(false);
@ -43,6 +53,8 @@ export default function ProjectsPage() {
new Set()
);
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [sortOption, setSortOption] = useState("createdAt-desc");
const handleCreateProject = async () => {
const projectId = await createNewProject("New Project");
@ -62,7 +74,7 @@ export default function ProjectsPage() {
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedProjects(new Set(savedProjects.map((p) => p.id)));
setSelectedProjects(new Set(sortedProjects.map((p) => p.id)));
} else {
setSelectedProjects(new Set());
}
@ -82,10 +94,13 @@ export default function ProjectsPage() {
setIsBulkDeleteDialogOpen(false);
};
const sortedProjects = getFilteredAndSortedProjects(searchQuery, sortOption);
const allSelected =
savedProjects.length > 0 && selectedProjects.size === savedProjects.length;
sortedProjects.length > 0 &&
selectedProjects.size === sortedProjects.length;
const someSelected =
selectedProjects.size > 0 && selectedProjects.size < savedProjects.length;
selectedProjects.size > 0 && selectedProjects.size < sortedProjects.length;
return (
<div className="min-h-screen bg-background">
@ -172,25 +187,40 @@ export default function ProjectsPage() {
</div>
</div>
{isSelectionMode && savedProjects.length > 0 && (
<div
className="mb-6 p-4 bg-muted/30 rounded-lg border cursor-pointer hover:bg-muted/40 transition-colors"
onClick={() => handleSelectAll(!allSelected)}
>
<div className="mb-4 flex items-center justify-between gap-4">
<div className="flex-1 max-w-72">
<Input
placeholder="Search projects..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Select value={sortOption} onValueChange={setSortOption}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Sort by" />
</SelectTrigger>
<SelectContent>
<SelectItem value="createdAt-desc">Newest to Oldest</SelectItem>
<SelectItem value="createdAt-asc">Oldest to Newest</SelectItem>
<SelectItem value="name-asc">Name (A-Z)</SelectItem>
<SelectItem value="name-desc">Name (Z-A)</SelectItem>
</SelectContent>
</Select>
</div>
{isSelectionMode && sortedProjects.length > 0 && (
<div className="mb-6 p-4 bg-muted/30 rounded-lg border">
<div className="flex items-center gap-3">
<Checkbox
checked={allSelected}
onCheckedChange={handleSelectAll}
onClick={(e) => e.stopPropagation()}
checked={someSelected ? "indeterminate" : allSelected}
onCheckedChange={(value) => handleSelectAll(!!value)}
/>
<div className="flex-1 flex items-center justify-start gap-2">
<span className="text-sm font-medium">
{allSelected ? "Deselect All" : "Select All"}
</span>
<span className="text-sm text-muted-foreground">
({selectedProjects.size} of {savedProjects.length} selected)
</span>
</div>
<span className="text-sm font-medium">
{allSelected ? "Deselect All" : "Select All"}
</span>
<span className="text-sm text-muted-foreground">
({selectedProjects.size} of {sortedProjects.length} selected)
</span>
</div>
</div>
)}
@ -201,15 +231,21 @@ export default function ProjectsPage() {
</div>
) : savedProjects.length === 0 ? (
<NoProjects onCreateProject={handleCreateProject} />
) : sortedProjects.length === 0 ? (
<NoResults
searchQuery={searchQuery}
onClearSearch={() => setSearchQuery("")}
/>
) : (
<div className="grid grid-cols-1 xs:grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-6">
{savedProjects.map((project) => (
{sortedProjects.map((project) => (
<ProjectCard
key={project.id}
project={project}
isSelectionMode={isSelectionMode}
isSelected={selectedProjects.has(project.id)}
onSelect={handleSelectProject}
searchQuery={searchQuery}
/>
))}
</div>
@ -230,6 +266,7 @@ interface ProjectCardProps {
isSelectionMode?: boolean;
isSelected?: boolean;
onSelect?: (projectId: string, checked: boolean) => void;
searchQuery?: string;
}
function ProjectCard({
@ -237,6 +274,7 @@ function ProjectCard({
isSelectionMode = false,
isSelected = false,
onSelect,
searchQuery,
}: ProjectCardProps) {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
@ -292,7 +330,7 @@ function ProjectCard({
{/* Selection checkbox */}
{isSelectionMode && (
<div className="absolute top-3 left-3 z-10">
<div className="w-5 h-5 rounded-full bg-background/80 backdrop-blur-sm border flex items-center justify-center">
<div className="w-5 h-5 rounded bg-background/80 backdrop-blur-sm border flex items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
@ -322,10 +360,10 @@ function ProjectCard({
</div>
</div>
<CardContent className="px-0 pt-5 flex flex-col gap-1 p-2">
<CardContent className="px-0 pt-5 flex flex-col gap-1">
<div className="flex items-start justify-between">
<h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
{project.name}
<Highlight text={project.name} query={searchQuery} />
</h3>
{!isSelectionMode && (
<DropdownMenu
@ -432,7 +470,7 @@ function ProjectCard({
<CardContent className="px-0 pt-5 flex flex-col gap-1">
<div className="flex items-start justify-between">
<h3 className="font-medium text-sm leading-snug group-hover:text-foreground/90 transition-colors line-clamp-2">
{project.name}
<Highlight text={project.name} query={searchQuery} />
</h3>
<DropdownMenu
open={isDropdownOpen}
@ -519,6 +557,28 @@ function ProjectCard({
);
}
function Highlight({ text, query }: { text: string; query?: string }) {
if (!query || query.trim() === "") {
return <span>{text}</span>;
}
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const parts = text.split(new RegExp(`(${escapedQuery})`, "gi"));
return (
<span>
{parts.map((part, i) =>
part.toLowerCase() === query.toLowerCase() ? (
<mark key={i} className="bg-yellow-200 text-black">
{part}
</mark>
) : (
part
)
)}
</span>
);
}
function CreateButton({ onClick }: { onClick?: () => void }) {
return (
<Button className="flex" onClick={onClick}>
@ -546,3 +606,26 @@ function NoProjects({ onCreateProject }: { onCreateProject: () => void }) {
</div>
);
}
function NoResults({
searchQuery,
onClearSearch,
}: {
searchQuery: string;
onClearSearch: () => void;
}) {
return (
<div className="flex flex-col items-center justify-center py-16 text-center">
<div className="w-16 h-16 rounded-full bg-muted/30 flex items-center justify-center mb-4">
<Search className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="text-lg font-medium mb-2">No results found</h3>
<p className="text-muted-foreground mb-6 max-w-md">
Your search for "{searchQuery}" did not return any results.
</p>
<Button onClick={onClearSearch} variant="outline">
Clear Search
</Button>
</div>
);
}

View File

@ -177,6 +177,7 @@ function PlusButton({
e.stopPropagation();
onClick?.();
}}
title={tooltipText}
>
<Plus className="!size-3" />
</Button>

View File

@ -27,6 +27,11 @@ interface ProjectStore {
options?: { backgroundColor?: string; blurIntensity?: number }
) => Promise<void>;
updateProjectFps: (fps: number) => Promise<void>;
getFilteredAndSortedProjects: (
searchQuery: string,
sortOption: string
) => TProject[];
}
export const useProjectStore = create<ProjectStore>((set, get) => ({
@ -317,4 +322,40 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
});
}
},
getFilteredAndSortedProjects: (searchQuery: string, sortOption: string) => {
const { savedProjects } = get();
// Filter projects by search query
const filteredProjects = savedProjects.filter((project) =>
project.name.toLowerCase().includes(searchQuery.toLowerCase())
);
// Sort filtered projects
const sortedProjects = [...filteredProjects].sort((a, b) => {
const [key, order] = sortOption.split("-");
if (key !== "createdAt" && key !== "name") {
console.warn(`Invalid sort key: ${key}`);
return 0;
}
const aValue = a[key];
const bValue = b[key];
if (aValue === undefined || bValue === undefined) return 0;
if (order === "asc") {
if (aValue < bValue) return -1;
if (aValue > bValue) return 1;
return 0;
} else {
if (aValue > bValue) return -1;
if (aValue < bValue) return 1;
return 0;
}
});
return sortedProjects;
},
}));