Compare commits

..
Author SHA1 Message Date
Maze Winther 6773009bd0 don't know 2026-01-30 20:38:01 +01:00
Maze Winther 10c39c6143 should i ship this? 2026-01-30 20:24:54 +01:00
Maze Winther 1fd74904e9 removed timeline anchor 2026-01-30 15:40:42 +01:00
Maze Winther d719460f32 save timeline view state 2026-01-30 15:08:43 +01:00
Maze Winther 488a9018b1 fix biome lints 2026-01-28 15:14:48 +01:00
Maze Winther 6d19841abf de-duplicate code 2026-01-28 15:12:14 +01:00
Maze Winther 9de4cee572 fix video node lag 2026-01-28 13:54:44 +01:00
Maze Winther 52733ae681 fix export cancel (don't show error) 2026-01-27 22:11:52 +01:00
Maze Winther 5705d09bf3 primary bg 2026-01-27 22:11:39 +01:00
Maze Winther 20f16aa2b4 fix zoom out 2026-01-27 21:58:53 +01:00
Maze Winther b6a8b67753 fix bookmarks issue 2026-01-27 21:14:01 +01:00
Maze Winther 63ee74c72b fix bookmarks bug 2026-01-27 20:36:54 +01:00
Maze Winther 8719f6db27 fix colors 2026-01-27 20:33:51 +01:00
Maze Winther 1d4aa18036 fix thumbnail generation 2026-01-27 20:19:18 +01:00
Maze Winther c71adc1525 formatting 2026-01-27 18:27:00 +01:00
Maze Winther 5d08b16867 continution from undo selection refactor 2026-01-27 17:52:29 +01:00
Maze Winther 9fc9a8ccc0 generate thumbnail from image elements 2026-01-27 17:51:56 +01:00
Maze Winther e38901fd66 refactor components structure 2026-01-27 17:26:53 +01:00
Maze Winther 94d4ee0c8c organize dialogs 2026-01-27 17:02:48 +01:00
Maze Winther 0a7dc295f8 finish export cancel feature 2026-01-27 16:58:38 +01:00
Maze Winther dd463ddc86 format files 2026-01-27 16:50:03 +01:00
Maze Winther fd5a53fc99 fix linter 2026-01-27 16:49:43 +01:00
Maze Winther 74c0ea7c4a random stuff 2026-01-27 16:48:38 +01:00
Maze Winther 091a5bbc57 formatting 2026-01-27 16:43:49 +01:00
Maze Winther d63f2625d8 another fix 2026-01-27 16:42:06 +01:00
Maze Winther 40e9fc12b7 fix 2026-01-27 16:41:03 +01:00
Maze Winther 1f8391487e we're basically done 2026-01-27 16:39:59 +01:00
Maze Winther af3100950e add escape route from projects page 2026-01-26 16:57:35 +01:00
Maze Winther 0c10bbf5ce projects page re-design and a ton of other stuff 2026-01-26 16:10:16 +01:00
Maze Winther 954160d03b lots more icons to do but 2026-01-24 15:53:46 +01:00
Maze Winther 33f453efc7 fix timeline zoom + selection box issues 2026-01-24 06:42:39 +01:00
Maze Winther 7de8e4172f i think we just fixed audio in the playback forever 2026-01-24 06:19:48 +01:00
Maze Winther bac5dc97dd that may not be saved dialog 2026-01-23 23:53:18 +01:00
Maze Winther de9b7a7350 refactor back to old icons system 2026-01-23 23:39:29 +01:00
Maze Winther 1d40795009 add stroke to icons 2026-01-23 23:30:58 +01:00
Maze Winther b5f305e4e7 fix all linter errors 2026-01-23 22:49:00 +01:00
Maze Winther cd0a8d020e update usage (icons + size-) 2026-01-23 21:25:51 +01:00
Maze Winther c6deceaa89 lots of stuff 2026-01-23 15:40:01 +01:00
Maze Winther b1701e1b0e why did we have this file 2026-01-21 14:58:03 +01:00
Maze Winther 9aeea153c7 woah 2026-01-21 14:57:40 +01:00
Maze Winther f3c91d5cb4 what am i doing 2026-01-21 14:54:12 +01:00
Maze Winther afdf7d22cf format all files 2026-01-21 12:52:34 +01:00
Maze Winther 8500dd770b uhh 2026-01-21 12:52:20 +01:00
Maze Winther e1d82eca09 shipping 2026-01-21 00:59:03 +01:00
Maze Winther 7f50de14fc move transcription to separate branch 2026-01-20 22:18:14 +01:00
Maze Winther 6e4069b42f shipping this slop 2026-01-20 22:14:49 +01:00
Maze Winther 7117f17ece new utility, less duplicated code 2026-01-18 09:16:47 +01:00
Maze Winther d260212b12 we're getting there 2026-01-18 08:46:14 +01:00
Maze Winther 981ac5e237 update deps 2026-01-18 06:26:55 +01:00
Maze Winther f18e9f4b4e fuck malware, fuck you 2026-01-18 05:44:25 +01:00
Maze Winther 0934db2aba holy cow 2026-01-16 18:07:28 +01:00
Maze Winther 0dddf4e13d move tools to separate branch 2026-01-15 11:50:42 +01:00
Maze Winther dba74fa20f yeah 2026-01-15 11:45:53 +01:00
Maze Winther deef784b76 stuff 2026-01-15 11:05:17 +01:00
Maze Winther c19f085e48 push 2026-01-05 01:35:10 +01:00
Maze Winther d5ca991501 yes 2025-12-25 21:15:36 +01:00
Maze Winther 2da84e7132 refactor packages, env, db and auth (prev commit) 2025-11-26 10:12:40 +01:00
Maze Winther 811491ba9c right before packages, env, db, and auth refactor
removing env validation, moving db and auth to each app
2025-11-26 09:42:44 +01:00
Maze Winther 04c09b439b add tools scripts to root package.json 2025-11-26 08:53:19 +01:00
Maze Winther 1b867e2b70 gitignore 2025-11-26 08:53:08 +01:00
Maze Winther 975560c28e remove files 2025-11-26 08:49:14 +01:00
Maze Winther efbebd13b8 refactor not done 2025-11-26 08:47:03 +01:00
22 changed files with 7237 additions and 1078 deletions
+6 -6
View File
@@ -1,7 +1,7 @@
Copyright 2025 OpenCut
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
Copyright 2025 OpenCut
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+280 -280
View File
@@ -1,280 +1,280 @@
import { webEnv } from "@opencut/env/web";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { checkRateLimit } from "@/lib/rate-limit";
const searchParamsSchema = z.object({
q: z.string().max(500, "Query too long").optional(),
type: z.enum(["songs", "effects"]).optional(),
page: z.coerce.number().int().min(1).max(1000).default(1),
page_size: z.coerce.number().int().min(1).max(150).default(20),
sort: z
.enum(["downloads", "rating", "created", "score"])
.default("downloads"),
min_rating: z.coerce.number().min(0).max(5).default(3),
commercial_only: z.coerce.boolean().default(true),
});
const freesoundResultSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string(),
url: z.string().url(),
previews: z
.object({
"preview-hq-mp3": z.string().url(),
"preview-lq-mp3": z.string().url(),
"preview-hq-ogg": z.string().url(),
"preview-lq-ogg": z.string().url(),
})
.optional(),
download: z.string().url().optional(),
duration: z.number(),
filesize: z.number(),
type: z.string(),
channels: z.number(),
bitrate: z.number(),
bitdepth: z.number(),
samplerate: z.number(),
username: z.string(),
tags: z.array(z.string()),
license: z.string(),
created: z.string(),
num_downloads: z.number().optional(),
avg_rating: z.number().optional(),
num_ratings: z.number().optional(),
});
const freesoundResponseSchema = z.object({
count: z.number(),
next: z.string().url().nullable(),
previous: z.string().url().nullable(),
results: z.array(freesoundResultSchema),
});
const transformedResultSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string(),
url: z.string(),
previewUrl: z.string().optional(),
downloadUrl: z.string().optional(),
duration: z.number(),
filesize: z.number(),
type: z.string(),
channels: z.number(),
bitrate: z.number(),
bitdepth: z.number(),
samplerate: z.number(),
username: z.string(),
tags: z.array(z.string()),
license: z.string(),
created: z.string(),
downloads: z.number().optional(),
rating: z.number().optional(),
ratingCount: z.number().optional(),
});
const apiResponseSchema = z.object({
count: z.number(),
next: z.string().nullable(),
previous: z.string().nullable(),
results: z.array(transformedResultSchema),
query: z.string().optional(),
type: z.string(),
page: z.number(),
pageSize: z.number(),
sort: z.string(),
minRating: z.number().optional(),
});
function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
if (!query) return `${sort}_desc`;
return sort === "score" ? "score" : `${sort}_desc`;
}
function applyEffectsFilters({
params,
min_rating,
commercial_only,
}: {
params: URLSearchParams;
min_rating: number;
commercial_only: boolean;
}) {
params.append("filter", "duration:[* TO 30.0]");
params.append("filter", `avg_rating:[${min_rating} TO *]`);
if (commercial_only) {
params.append(
"filter",
'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
);
}
params.append(
"filter",
"tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
);
}
function transformFreesoundResult(
result: z.infer<typeof freesoundResultSchema>,
) {
return {
id: result.id,
name: result.name,
description: result.description,
url: result.url,
previewUrl:
result.previews?.["preview-hq-mp3"] ||
result.previews?.["preview-lq-mp3"],
downloadUrl: result.download,
duration: result.duration,
filesize: result.filesize,
type: result.type,
channels: result.channels,
bitrate: result.bitrate,
bitdepth: result.bitdepth,
samplerate: result.samplerate,
username: result.username,
tags: result.tags,
license: result.license,
created: result.created,
downloads: result.num_downloads || 0,
rating: result.avg_rating || 0,
ratingCount: result.num_ratings || 0,
};
}
export async function GET(request: NextRequest) {
try {
const { limited } = await checkRateLimit({ request });
if (limited) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
const { searchParams } = new URL(request.url);
const validationResult = searchParamsSchema.safeParse({
q: searchParams.get("q") || undefined,
type: searchParams.get("type") || undefined,
page: searchParams.get("page") || undefined,
page_size: searchParams.get("page_size") || undefined,
sort: searchParams.get("sort") || undefined,
min_rating: searchParams.get("min_rating") || undefined,
});
if (!validationResult.success) {
return NextResponse.json(
{
error: "Invalid parameters",
details: validationResult.error.flatten().fieldErrors,
},
{ status: 400 },
);
}
const {
q: query,
type,
page,
page_size: pageSize,
sort,
min_rating,
commercial_only,
} = validationResult.data;
if (type === "songs") {
return NextResponse.json(
{
error: "Songs are not available yet",
message:
"Song search functionality is coming soon. Try searching for sound effects instead.",
},
{ status: 501 },
);
}
const baseUrl = "https://freesound.org/apiv2/search/text/";
const sortParam = buildSortParameter({ query, sort });
const params = new URLSearchParams({
query: query || "",
token: webEnv.FREESOUND_API_KEY,
page: page.toString(),
page_size: pageSize.toString(),
sort: sortParam,
fields:
"id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
});
const isEffectsSearch = type === "effects" || !type;
if (isEffectsSearch) {
applyEffectsFilters({ params, min_rating, commercial_only });
}
const response = await fetch(`${baseUrl}?${params.toString()}`);
if (!response.ok) {
const errorText = await response.text();
console.error("Freesound API error:", response.status, errorText);
return NextResponse.json(
{ error: "Failed to search sounds" },
{ status: response.status },
);
}
const rawData = await response.json();
const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
if (!freesoundValidation.success) {
console.error(
"Invalid Freesound API response:",
freesoundValidation.error,
);
return NextResponse.json(
{ error: "Invalid response from Freesound API" },
{ status: 502 },
);
}
const data = freesoundValidation.data;
const transformedResults = data.results.map(transformFreesoundResult);
const responseData = {
count: data.count,
next: data.next,
previous: data.previous,
results: transformedResults,
query: query || "",
type: type || "effects",
page,
pageSize,
sort,
minRating: min_rating,
};
const responseValidation = apiResponseSchema.safeParse(responseData);
if (!responseValidation.success) {
console.error(
"Invalid API response structure:",
responseValidation.error,
);
return NextResponse.json(
{ error: "Internal response formatting error" },
{ status: 500 },
);
}
return NextResponse.json(responseValidation.data);
} catch (error) {
console.error("Error searching sounds:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
import { webEnv } from "@opencut/env/web";
import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { checkRateLimit } from "@/lib/rate-limit";
const searchParamsSchema = z.object({
q: z.string().max(500, "Query too long").optional(),
type: z.enum(["songs", "effects"]).optional(),
page: z.coerce.number().int().min(1).max(1000).default(1),
page_size: z.coerce.number().int().min(1).max(150).default(20),
sort: z
.enum(["downloads", "rating", "created", "score"])
.default("downloads"),
min_rating: z.coerce.number().min(0).max(5).default(3),
commercial_only: z.coerce.boolean().default(true),
});
const freesoundResultSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string(),
url: z.string().url(),
previews: z
.object({
"preview-hq-mp3": z.string().url(),
"preview-lq-mp3": z.string().url(),
"preview-hq-ogg": z.string().url(),
"preview-lq-ogg": z.string().url(),
})
.optional(),
download: z.string().url().optional(),
duration: z.number(),
filesize: z.number(),
type: z.string(),
channels: z.number(),
bitrate: z.number(),
bitdepth: z.number(),
samplerate: z.number(),
username: z.string(),
tags: z.array(z.string()),
license: z.string(),
created: z.string(),
num_downloads: z.number().optional(),
avg_rating: z.number().optional(),
num_ratings: z.number().optional(),
});
const freesoundResponseSchema = z.object({
count: z.number(),
next: z.string().url().nullable(),
previous: z.string().url().nullable(),
results: z.array(freesoundResultSchema),
});
const transformedResultSchema = z.object({
id: z.number(),
name: z.string(),
description: z.string(),
url: z.string(),
previewUrl: z.string().optional(),
downloadUrl: z.string().optional(),
duration: z.number(),
filesize: z.number(),
type: z.string(),
channels: z.number(),
bitrate: z.number(),
bitdepth: z.number(),
samplerate: z.number(),
username: z.string(),
tags: z.array(z.string()),
license: z.string(),
created: z.string(),
downloads: z.number().optional(),
rating: z.number().optional(),
ratingCount: z.number().optional(),
});
const apiResponseSchema = z.object({
count: z.number(),
next: z.string().nullable(),
previous: z.string().nullable(),
results: z.array(transformedResultSchema),
query: z.string().optional(),
type: z.string(),
page: z.number(),
pageSize: z.number(),
sort: z.string(),
minRating: z.number().optional(),
});
function buildSortParameter({ query, sort }: { query?: string; sort: string }) {
if (!query) return `${sort}_desc`;
return sort === "score" ? "score" : `${sort}_desc`;
}
function applyEffectsFilters({
params,
min_rating,
commercial_only,
}: {
params: URLSearchParams;
min_rating: number;
commercial_only: boolean;
}) {
params.append("filter", "duration:[* TO 30.0]");
params.append("filter", `avg_rating:[${min_rating} TO *]`);
if (commercial_only) {
params.append(
"filter",
'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")',
);
}
params.append(
"filter",
"tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion",
);
}
function transformFreesoundResult(
result: z.infer<typeof freesoundResultSchema>,
) {
return {
id: result.id,
name: result.name,
description: result.description,
url: result.url,
previewUrl:
result.previews?.["preview-hq-mp3"] ||
result.previews?.["preview-lq-mp3"],
downloadUrl: result.download,
duration: result.duration,
filesize: result.filesize,
type: result.type,
channels: result.channels,
bitrate: result.bitrate,
bitdepth: result.bitdepth,
samplerate: result.samplerate,
username: result.username,
tags: result.tags,
license: result.license,
created: result.created,
downloads: result.num_downloads || 0,
rating: result.avg_rating || 0,
ratingCount: result.num_ratings || 0,
};
}
export async function GET(request: NextRequest) {
try {
const { limited } = await checkRateLimit({ request });
if (limited) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
const { searchParams } = new URL(request.url);
const validationResult = searchParamsSchema.safeParse({
q: searchParams.get("q") || undefined,
type: searchParams.get("type") || undefined,
page: searchParams.get("page") || undefined,
page_size: searchParams.get("page_size") || undefined,
sort: searchParams.get("sort") || undefined,
min_rating: searchParams.get("min_rating") || undefined,
});
if (!validationResult.success) {
return NextResponse.json(
{
error: "Invalid parameters",
details: validationResult.error.flatten().fieldErrors,
},
{ status: 400 },
);
}
const {
q: query,
type,
page,
page_size: pageSize,
sort,
min_rating,
commercial_only,
} = validationResult.data;
if (type === "songs") {
return NextResponse.json(
{
error: "Songs are not available yet",
message:
"Song search functionality is coming soon. Try searching for sound effects instead.",
},
{ status: 501 },
);
}
const baseUrl = "https://freesound.org/apiv2/search/text/";
const sortParam = buildSortParameter({ query, sort });
const params = new URLSearchParams({
query: query || "",
token: webEnv.FREESOUND_API_KEY,
page: page.toString(),
page_size: pageSize.toString(),
sort: sortParam,
fields:
"id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings",
});
const isEffectsSearch = type === "effects" || !type;
if (isEffectsSearch) {
applyEffectsFilters({ params, min_rating, commercial_only });
}
const response = await fetch(`${baseUrl}?${params.toString()}`);
if (!response.ok) {
const errorText = await response.text();
console.error("Freesound API error:", response.status, errorText);
return NextResponse.json(
{ error: "Failed to search sounds" },
{ status: response.status },
);
}
const rawData = await response.json();
const freesoundValidation = freesoundResponseSchema.safeParse(rawData);
if (!freesoundValidation.success) {
console.error(
"Invalid Freesound API response:",
freesoundValidation.error,
);
return NextResponse.json(
{ error: "Invalid response from Freesound API" },
{ status: 502 },
);
}
const data = freesoundValidation.data;
const transformedResults = data.results.map(transformFreesoundResult);
const responseData = {
count: data.count,
next: data.next,
previous: data.previous,
results: transformedResults,
query: query || "",
type: type || "effects",
page,
pageSize,
sort,
minRating: min_rating,
};
const responseValidation = apiResponseSchema.safeParse(responseData);
if (!responseValidation.success) {
console.error(
"Invalid API response structure:",
responseValidation.error,
);
return NextResponse.json(
{ error: "Internal response formatting error" },
{ status: 500 },
);
}
return NextResponse.json(responseValidation.data);
} catch (error) {
console.error("Error searching sounds:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -1,27 +1,27 @@
"use client";
import { useEditorStore } from "@/stores/editor-store";
import Image from "next/image";
function TikTokGuide() {
return (
<div className="pointer-events-none absolute inset-0">
<Image
src="/platform-guides/tiktok-blueprint.png"
alt="TikTok layout guide"
className="absolute inset-0 size-full object-contain"
draggable={false}
fill
/>
</div>
);
}
export function LayoutGuideOverlay() {
const { layoutGuide } = useEditorStore();
if (layoutGuide.platform === null) return null;
if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
return null;
}
"use client";
import { useEditorStore } from "@/stores/editor-store";
import Image from "next/image";
function TikTokGuide() {
return (
<div className="pointer-events-none absolute inset-0">
<Image
src="/platform-guides/tiktok-blueprint.png"
alt="TikTok layout guide"
className="absolute inset-0 size-full object-contain"
draggable={false}
fill
/>
</div>
);
}
export function LayoutGuideOverlay() {
const { layoutGuide } = useEditorStore();
if (layoutGuide.platform === null) return null;
if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
return null;
}
+36 -36
View File
@@ -1,36 +1,36 @@
"use client";
import { Button } from "./ui/button";
import { useTheme } from "next-themes";
import { cn } from "@/utils/ui";
import { Sun03Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
interface ThemeToggleProps {
className?: string;
iconClassName?: string;
onToggle?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}
export function ThemeToggle({
className,
iconClassName,
onToggle,
}: ThemeToggleProps) {
const { theme, setTheme } = useTheme();
return (
<Button
size="icon"
variant="text"
className={cn("h-7", className)}
onClick={(e) => {
setTheme(theme === "dark" ? "light" : "dark");
onToggle?.(e);
}}
>
<HugeiconsIcon icon={Sun03Icon} className={cn("!size-[1.1rem]", iconClassName)} />
<span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span>
</Button>
);
}
"use client";
import { Button } from "./ui/button";
import { useTheme } from "next-themes";
import { cn } from "@/utils/ui";
import { Sun03Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
interface ThemeToggleProps {
className?: string;
iconClassName?: string;
onToggle?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}
export function ThemeToggle({
className,
iconClassName,
onToggle,
}: ThemeToggleProps) {
const { theme, setTheme } = useTheme();
return (
<Button
size="icon"
variant="text"
className={cn("h-7", className)}
onClick={(e) => {
setTheme(theme === "dark" ? "light" : "dark");
onToggle?.(e);
}}
>
<HugeiconsIcon icon={Sun03Icon} className={cn("!size-[1.1rem]", iconClassName)} />
<span className="sr-only">{theme === "dark" ? "Light" : "Dark"}</span>
</Button>
);
}
+88 -88
View File
@@ -1,88 +1,88 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ArrowLeft, Search } from "lucide-react";
import { motion } from "motion/react";
import { useState, useEffect } from "react";
interface InputWithBackProps {
isExpanded: boolean;
setIsExpanded: (isExpanded: boolean) => void;
placeholder?: string;
value?: string;
onChange?: (value: string) => void;
disableAnimation?: boolean;
}
export function InputWithBack({
isExpanded,
setIsExpanded,
placeholder = "Search anything",
value,
onChange,
disableAnimation = false,
}: InputWithBackProps) {
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
const [buttonOffset, setButtonOffset] = useState(-60);
const smoothTransition = {
duration: disableAnimation ? 0 : 0.35,
ease: [0.25, 0.1, 0.25, 1] as const,
};
useEffect(() => {
if (containerRef) {
const rect = containerRef.getBoundingClientRect();
setButtonOffset(-rect.left - 48);
}
}, [containerRef]);
return (
<div ref={setContainerRef} className="relative w-full">
<motion.div
className="absolute top-1/2 left-0 z-10 -translate-y-1/2 cursor-pointer hover:opacity-75"
initial={{
x: isExpanded ? 0 : buttonOffset,
opacity: isExpanded ? 1 : 0.5,
}}
animate={{
x: isExpanded ? 0 : buttonOffset,
opacity: isExpanded ? 1 : 0.5,
}}
transition={smoothTransition}
onClick={() => setIsExpanded(!isExpanded)}
>
<Button
variant="outline"
className="bg-panel-accent !size-9 rounded-full"
>
<ArrowLeft />
</Button>
</motion.div>
<div
className="relative flex-1"
style={{ marginLeft: "0px", paddingLeft: "0px" }}
>
<motion.div
className="relative"
initial={{
marginLeft: isExpanded ? 50 : 0,
}}
animate={{
marginLeft: isExpanded ? 50 : 0,
}}
transition={smoothTransition}
>
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
placeholder={placeholder}
className="bg-panel-accent w-full pl-9"
value={value}
onChange={(e) => onChange?.(e.target.value)}
/>
</motion.div>
</div>
</div>
);
}
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ArrowLeft, Search } from "lucide-react";
import { motion } from "motion/react";
import { useState, useEffect } from "react";
interface InputWithBackProps {
isExpanded: boolean;
setIsExpanded: (isExpanded: boolean) => void;
placeholder?: string;
value?: string;
onChange?: (value: string) => void;
disableAnimation?: boolean;
}
export function InputWithBack({
isExpanded,
setIsExpanded,
placeholder = "Search anything",
value,
onChange,
disableAnimation = false,
}: InputWithBackProps) {
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
const [buttonOffset, setButtonOffset] = useState(-60);
const smoothTransition = {
duration: disableAnimation ? 0 : 0.35,
ease: [0.25, 0.1, 0.25, 1] as const,
};
useEffect(() => {
if (containerRef) {
const rect = containerRef.getBoundingClientRect();
setButtonOffset(-rect.left - 48);
}
}, [containerRef]);
return (
<div ref={setContainerRef} className="relative w-full">
<motion.div
className="absolute top-1/2 left-0 z-10 -translate-y-1/2 cursor-pointer hover:opacity-75"
initial={{
x: isExpanded ? 0 : buttonOffset,
opacity: isExpanded ? 1 : 0.5,
}}
animate={{
x: isExpanded ? 0 : buttonOffset,
opacity: isExpanded ? 1 : 0.5,
}}
transition={smoothTransition}
onClick={() => setIsExpanded(!isExpanded)}
>
<Button
variant="outline"
className="bg-panel-accent !size-9 rounded-full"
>
<ArrowLeft />
</Button>
</motion.div>
<div
className="relative flex-1"
style={{ marginLeft: "0px", paddingLeft: "0px" }}
>
<motion.div
className="relative"
initial={{
marginLeft: isExpanded ? 50 : 0,
}}
animate={{
marginLeft: isExpanded ? 50 : 0,
}}
transition={smoothTransition}
>
<Search className="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
placeholder={placeholder}
className="bg-panel-accent w-full pl-9"
value={value}
onChange={(e) => onChange?.(e.target.value)}
/>
</motion.div>
</div>
</div>
);
}
+90 -90
View File
@@ -1,90 +1,90 @@
import { Button, type ButtonProps } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { type ReactNode, forwardRef } from "react";
import { cn } from "@/utils/ui";
interface SplitButtonProps {
children: ReactNode;
className?: string;
}
interface SplitButtonSideProps extends Omit<ButtonProps, "variant" | "size"> {
children: ReactNode;
}
const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>(
({ children, className, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
"border-input bg-panel-accent inline-flex h-7 overflow-hidden rounded-lg border",
className,
)}
{...props}
>
{children}
</div>
);
},
);
SplitButton.displayName = "SplitButton";
const SplitButtonSide = forwardRef<
HTMLButtonElement,
SplitButtonSideProps & { paddingClass: string }
>(({ children, className, paddingClass, onClick, ...props }, ref) => {
return (
<Button
ref={ref}
variant="text"
className={cn(
"bg-panel-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100",
onClick
? "hover:bg-foreground/10 cursor-pointer hover:opacity-100"
: "cursor-default select-text",
paddingClass,
className,
)}
onClick={onClick}
{...props}
>
{typeof children === "string" ? (
<span className="cursor-text font-normal">{children}</span>
) : (
children
)}
</Button>
);
});
SplitButtonSide.displayName = "SplitButtonSide";
const SplitButtonLeft = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
({ ...props }, ref) => {
return <SplitButtonSide ref={ref} paddingClass="pl-3 pr-2" {...props} />;
},
);
SplitButtonLeft.displayName = "SplitButtonLeft";
const SplitButtonRight = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
({ ...props }, ref) => {
return <SplitButtonSide ref={ref} paddingClass="pl-2 pr-3" {...props} />;
},
);
SplitButtonRight.displayName = "SplitButtonRight";
const SplitButtonSeparator = forwardRef<HTMLDivElement, { className?: string }>(
({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
orientation="vertical"
className={cn("bg-foreground/15 h-full", className)}
{...props}
/>
);
},
);
SplitButtonSeparator.displayName = "SplitButtonSeparator";
export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator };
import { Button, type ButtonProps } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { type ReactNode, forwardRef } from "react";
import { cn } from "@/utils/ui";
interface SplitButtonProps {
children: ReactNode;
className?: string;
}
interface SplitButtonSideProps extends Omit<ButtonProps, "variant" | "size"> {
children: ReactNode;
}
const SplitButton = forwardRef<HTMLDivElement, SplitButtonProps>(
({ children, className, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
"border-input bg-panel-accent inline-flex h-7 overflow-hidden rounded-lg border",
className,
)}
{...props}
>
{children}
</div>
);
},
);
SplitButton.displayName = "SplitButton";
const SplitButtonSide = forwardRef<
HTMLButtonElement,
SplitButtonSideProps & { paddingClass: string }
>(({ children, className, paddingClass, onClick, ...props }, ref) => {
return (
<Button
ref={ref}
variant="text"
className={cn(
"bg-panel-accent disabled:text-muted-foreground h-full gap-0 rounded-none border-0 font-normal !opacity-100",
onClick
? "hover:bg-foreground/10 cursor-pointer hover:opacity-100"
: "cursor-default select-text",
paddingClass,
className,
)}
onClick={onClick}
{...props}
>
{typeof children === "string" ? (
<span className="cursor-text font-normal">{children}</span>
) : (
children
)}
</Button>
);
});
SplitButtonSide.displayName = "SplitButtonSide";
const SplitButtonLeft = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
({ ...props }, ref) => {
return <SplitButtonSide ref={ref} paddingClass="pl-3 pr-2" {...props} />;
},
);
SplitButtonLeft.displayName = "SplitButtonLeft";
const SplitButtonRight = forwardRef<HTMLButtonElement, SplitButtonSideProps>(
({ ...props }, ref) => {
return <SplitButtonSide ref={ref} paddingClass="pl-2 pr-3" {...props} />;
},
);
SplitButtonRight.displayName = "SplitButtonRight";
const SplitButtonSeparator = forwardRef<HTMLDivElement, { className?: string }>(
({ className, ...props }, ref) => {
return (
<Separator
ref={ref}
orientation="vertical"
className={cn("bg-foreground/15 h-full", className)}
{...props}
/>
);
},
);
SplitButtonSeparator.displayName = "SplitButtonSeparator";
export { SplitButton, SplitButtonLeft, SplitButtonRight, SplitButtonSeparator };
+29 -29
View File
@@ -1,29 +1,29 @@
import type { TextElement } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "./timeline-constants";
export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = {
type: "text",
name: "Text",
content: "Default Text",
fontSize: 48,
fontFamily: "Arial",
color: "#ffffff",
backgroundColor: "transparent",
textAlign: "center",
fontWeight: "normal",
fontStyle: "normal",
textDecoration: "none",
duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
startTime: 0,
trimStart: 0,
trimEnd: 0,
transform: {
scale: 1,
position: {
x: 0,
y: 0,
},
rotate: 0,
},
opacity: 1,
};
import type { TextElement } from "@/types/timeline";
import { TIMELINE_CONSTANTS } from "./timeline-constants";
export const DEFAULT_TEXT_ELEMENT: Omit<TextElement, "id"> = {
type: "text",
name: "Text",
content: "Default Text",
fontSize: 48,
fontFamily: "Arial",
color: "#ffffff",
backgroundColor: "transparent",
textAlign: "center",
fontWeight: "normal",
fontStyle: "normal",
textDecoration: "none",
duration: TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION,
startTime: 0,
trimStart: 0,
trimEnd: 0,
transform: {
scale: 1,
position: {
x: 0,
y: 0,
},
rotate: 0,
},
opacity: 1,
};
+35 -35
View File
@@ -1,35 +1,35 @@
import { useRef, useCallback } from "react";
interface UseInfiniteScrollOptions {
onLoadMore: () => void;
hasMore: boolean;
isLoading: boolean;
threshold?: number;
enabled?: boolean;
}
export function useInfiniteScroll({
onLoadMore,
hasMore,
isLoading,
threshold = 200,
enabled = true,
}: UseInfiniteScrollOptions) {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
if (!enabled) return;
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
if (nearBottom && hasMore && !isLoading) {
onLoadMore();
}
},
[onLoadMore, hasMore, isLoading, threshold, enabled],
);
return { scrollAreaRef, handleScroll };
}
import { useRef, useCallback } from "react";
interface UseInfiniteScrollOptions {
onLoadMore: () => void;
hasMore: boolean;
isLoading: boolean;
threshold?: number;
enabled?: boolean;
}
export function useInfiniteScroll({
onLoadMore,
hasMore,
isLoading,
threshold = 200,
enabled = true,
}: UseInfiniteScrollOptions) {
const scrollAreaRef = useRef<HTMLDivElement>(null);
const handleScroll = useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
if (!enabled) return;
const { scrollTop, scrollHeight, clientHeight } = event.currentTarget;
const nearBottom = scrollTop + clientHeight >= scrollHeight - threshold;
if (nearBottom && hasMore && !isLoading) {
onLoadMore();
}
},
[onLoadMore, hasMore, isLoading, threshold, enabled],
);
return { scrollAreaRef, handleScroll };
}
+154 -154
View File
@@ -1,154 +1,154 @@
import { useEffect } from "react";
import { useSoundsStore } from "@/stores/sounds-store";
export function useSoundSearch({
query,
commercialOnly,
}: {
query: string;
commercialOnly: boolean;
}) {
const {
searchResults,
isSearching,
searchError,
lastSearchQuery,
currentPage,
hasNextPage,
isLoadingMore,
totalCount,
setSearchResults,
setSearching,
setSearchError,
setLastSearchQuery,
setCurrentPage,
setHasNextPage,
setTotalCount,
setLoadingMore,
appendSearchResults,
appendTopSounds,
resetPagination,
} = useSoundsStore();
const loadMore = async () => {
if (isLoadingMore || !hasNextPage) return;
try {
setLoadingMore({ loading: true });
const nextPage = currentPage + 1;
const searchParams = new URLSearchParams({
page: nextPage.toString(),
type: "effects",
});
if (query.trim()) {
searchParams.set("q", query);
}
searchParams.set("commercial_only", commercialOnly.toString());
const response = await fetch(
`/api/sounds/search?${searchParams.toString()}`,
);
if (response.ok) {
const data = await response.json();
if (query.trim()) {
appendSearchResults(data.results);
} else {
appendTopSounds(data.results);
}
setCurrentPage({ page: nextPage });
setHasNextPage({ hasNext: !!data.next });
setTotalCount(data.count);
} else {
setSearchError({ error: `Load more failed: ${response.status}` });
}
} catch (err) {
setSearchError({
error: err instanceof Error ? err.message : "Load more failed",
});
} finally {
setLoadingMore({ loading: false });
}
};
useEffect(() => {
if (!query.trim()) {
setSearchResults({ results: [] });
setSearchError({ error: null });
setLastSearchQuery({ query: "" });
return;
}
if (query === lastSearchQuery && searchResults.length > 0) {
return;
}
let ignore = false;
const timeoutId = setTimeout(async () => {
try {
setSearching({ searching: true });
setSearchError({ error: null });
resetPagination();
const response = await fetch(
`/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`,
);
if (!ignore) {
if (response.ok) {
const data = await response.json();
setSearchResults({ results: data.results });
setLastSearchQuery({ query: query });
setHasNextPage({ hasNext: !!data.next });
setTotalCount({ count: data.count });
setCurrentPage({ page: 1 });
} else {
setSearchError({ error: `Search failed: ${response.status}` });
}
}
} catch (err) {
if (!ignore) {
setSearchError({
error: err instanceof Error ? err.message : "Search failed",
});
}
} finally {
if (!ignore) {
setSearching({ searching: false });
}
}
}, 300);
return () => {
clearTimeout(timeoutId);
ignore = true;
};
}, [
query,
lastSearchQuery,
searchResults.length,
setSearchResults,
setSearching,
setSearchError,
setLastSearchQuery,
setCurrentPage,
setHasNextPage,
setTotalCount,
resetPagination,
]);
return {
results: searchResults,
isLoading: isSearching,
error: searchError,
loadMore,
hasNextPage,
isLoadingMore,
totalCount,
};
}
import { useEffect } from "react";
import { useSoundsStore } from "@/stores/sounds-store";
export function useSoundSearch({
query,
commercialOnly,
}: {
query: string;
commercialOnly: boolean;
}) {
const {
searchResults,
isSearching,
searchError,
lastSearchQuery,
currentPage,
hasNextPage,
isLoadingMore,
totalCount,
setSearchResults,
setSearching,
setSearchError,
setLastSearchQuery,
setCurrentPage,
setHasNextPage,
setTotalCount,
setLoadingMore,
appendSearchResults,
appendTopSounds,
resetPagination,
} = useSoundsStore();
const loadMore = async () => {
if (isLoadingMore || !hasNextPage) return;
try {
setLoadingMore({ loading: true });
const nextPage = currentPage + 1;
const searchParams = new URLSearchParams({
page: nextPage.toString(),
type: "effects",
});
if (query.trim()) {
searchParams.set("q", query);
}
searchParams.set("commercial_only", commercialOnly.toString());
const response = await fetch(
`/api/sounds/search?${searchParams.toString()}`,
);
if (response.ok) {
const data = await response.json();
if (query.trim()) {
appendSearchResults(data.results);
} else {
appendTopSounds(data.results);
}
setCurrentPage({ page: nextPage });
setHasNextPage({ hasNext: !!data.next });
setTotalCount(data.count);
} else {
setSearchError({ error: `Load more failed: ${response.status}` });
}
} catch (err) {
setSearchError({
error: err instanceof Error ? err.message : "Load more failed",
});
} finally {
setLoadingMore({ loading: false });
}
};
useEffect(() => {
if (!query.trim()) {
setSearchResults({ results: [] });
setSearchError({ error: null });
setLastSearchQuery({ query: "" });
return;
}
if (query === lastSearchQuery && searchResults.length > 0) {
return;
}
let ignore = false;
const timeoutId = setTimeout(async () => {
try {
setSearching({ searching: true });
setSearchError({ error: null });
resetPagination();
const response = await fetch(
`/api/sounds/search?q=${encodeURIComponent(query)}&type=effects&page=1`,
);
if (!ignore) {
if (response.ok) {
const data = await response.json();
setSearchResults({ results: data.results });
setLastSearchQuery({ query: query });
setHasNextPage({ hasNext: !!data.next });
setTotalCount({ count: data.count });
setCurrentPage({ page: 1 });
} else {
setSearchError({ error: `Search failed: ${response.status}` });
}
}
} catch (err) {
if (!ignore) {
setSearchError({
error: err instanceof Error ? err.message : "Search failed",
});
}
} finally {
if (!ignore) {
setSearching({ searching: false });
}
}
}, 300);
return () => {
clearTimeout(timeoutId);
ignore = true;
};
}, [
query,
lastSearchQuery,
searchResults.length,
setSearchResults,
setSearching,
setSearchError,
setLastSearchQuery,
setCurrentPage,
setHasNextPage,
setTotalCount,
resetPagination,
]);
return {
results: searchResults,
isLoading: isSearching,
error: searchError,
loadMore,
hasNextPage,
isLoadingMore,
totalCount,
};
}
+261 -261
View File
@@ -1,261 +1,261 @@
import { create } from "zustand";
import type { SoundEffect, SavedSound } from "@/types/sounds";
import { storageService } from "@/services/storage/storage-service";
import { toast } from "sonner";
import { EditorCore } from "@/core";
import { buildLibraryAudioElement } from "@/lib/timeline/element-utils";
interface SoundsStore {
topSoundEffects: SoundEffect[];
isLoading: boolean;
error: string | null;
hasLoaded: boolean;
showCommercialOnly: boolean;
toggleCommercialFilter: () => void;
searchQuery: string;
searchResults: SoundEffect[];
isSearching: boolean;
searchError: string | null;
lastSearchQuery: string;
scrollPosition: number;
currentPage: number;
hasNextPage: boolean;
totalCount: number;
isLoadingMore: boolean;
savedSounds: SavedSound[];
isSavedSoundsLoaded: boolean;
isLoadingSavedSounds: boolean;
savedSoundsError: string | null;
addSoundToTimeline: ({ sound }: { sound: SoundEffect }) => Promise<boolean>;
setTopSoundEffects: ({ sounds }: { sounds: SoundEffect[] }) => void;
setLoading: ({ loading }: { loading: boolean }) => void;
setError: ({ error }: { error: string | null }) => void;
setHasLoaded: ({ loaded }: { loaded: boolean }) => void;
setSearchQuery: ({ query }: { query: string }) => void;
setSearchResults: ({ results }: { results: SoundEffect[] }) => void;
setSearching: ({ searching }: { searching: boolean }) => void;
setSearchError: ({ error }: { error: string | null }) => void;
setLastSearchQuery: ({ query }: { query: string }) => void;
setScrollPosition: ({ position }: { position: number }) => void;
setCurrentPage: ({ page }: { page: number }) => void;
setHasNextPage: ({ hasNext }: { hasNext: boolean }) => void;
setTotalCount: ({ count }: { count: number }) => void;
setLoadingMore: ({ loading }: { loading: boolean }) => void;
appendSearchResults: ({ results }: { results: SoundEffect[] }) => void;
appendTopSounds: ({ results }: { results: SoundEffect[] }) => void;
resetPagination: () => void;
loadSavedSounds: () => Promise<void>;
saveSoundEffect: ({
soundEffect,
}: {
soundEffect: SoundEffect;
}) => Promise<void>;
removeSavedSound: ({ soundId }: { soundId: number }) => Promise<void>;
isSoundSaved: ({ soundId }: { soundId: number }) => boolean;
toggleSavedSound: ({
soundEffect,
}: {
soundEffect: SoundEffect;
}) => Promise<void>;
clearSavedSounds: () => Promise<void>;
}
export const useSoundsStore = create<SoundsStore>((set, get) => ({
topSoundEffects: [],
isLoading: false,
error: null,
hasLoaded: false,
showCommercialOnly: true,
toggleCommercialFilter: () => {
set((state) => ({ showCommercialOnly: !state.showCommercialOnly }));
},
searchQuery: "",
searchResults: [],
isSearching: false,
searchError: null,
lastSearchQuery: "",
scrollPosition: 0,
currentPage: 1,
hasNextPage: false,
totalCount: 0,
isLoadingMore: false,
savedSounds: [],
isSavedSoundsLoaded: false,
isLoadingSavedSounds: false,
savedSoundsError: null,
setTopSoundEffects: ({ sounds }) => set({ topSoundEffects: sounds }),
setLoading: ({ loading }) => set({ isLoading: loading }),
setError: ({ error }) => set({ error }),
setHasLoaded: ({ loaded }) => set({ hasLoaded: loaded }),
setSearchQuery: ({ query }) => set({ searchQuery: query }),
setSearchResults: ({ results }) =>
set({ searchResults: results, currentPage: 1 }),
setSearching: ({ searching }) => set({ isSearching: searching }),
setSearchError: ({ error }) => set({ searchError: error }),
setLastSearchQuery: ({ query }) => set({ lastSearchQuery: query }),
setScrollPosition: ({ position }) => set({ scrollPosition: position }),
setCurrentPage: ({ page }) => set({ currentPage: page }),
setHasNextPage: ({ hasNext }) => set({ hasNextPage: hasNext }),
setTotalCount: ({ count }) => set({ totalCount: count }),
setLoadingMore: ({ loading }) => set({ isLoadingMore: loading }),
appendSearchResults: ({ results }) =>
set((state) => ({
searchResults: [...state.searchResults, ...results],
})),
appendTopSounds: ({ results }) =>
set((state) => ({
topSoundEffects: [...state.topSoundEffects, ...results],
})),
resetPagination: () =>
set({
currentPage: 1,
hasNextPage: false,
totalCount: 0,
isLoadingMore: false,
}),
loadSavedSounds: async () => {
if (get().isSavedSoundsLoaded) return;
try {
set({ isLoadingSavedSounds: true, savedSoundsError: null });
const savedSoundsData = await storageService.loadSavedSounds();
set({
savedSounds: savedSoundsData.sounds,
isSavedSoundsLoaded: true,
isLoadingSavedSounds: false,
});
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Failed to load saved sounds";
set({
savedSoundsError: errorMessage,
isLoadingSavedSounds: false,
});
console.error("Failed to load saved sounds:", error);
}
},
saveSoundEffect: async ({ soundEffect }) => {
try {
await storageService.saveSoundEffect({ soundEffect });
const savedSoundsData = await storageService.loadSavedSounds();
set({ savedSounds: savedSoundsData.sounds });
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Failed to save sound";
set({ savedSoundsError: errorMessage });
toast.error("Failed to save sound");
console.error("Failed to save sound:", error);
}
},
removeSavedSound: async ({ soundId }) => {
try {
await storageService.removeSavedSound({ soundId });
set((state) => ({
savedSounds: state.savedSounds.filter((sound) => sound.id !== soundId),
}));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Failed to remove sound";
set({ savedSoundsError: errorMessage });
toast.error("Failed to remove sound");
console.error("Failed to remove sound:", error);
}
},
isSoundSaved: ({ soundId }) => {
const { savedSounds } = get();
return savedSounds.some((sound) => sound.id === soundId);
},
toggleSavedSound: async ({ soundEffect }) => {
const { isSoundSaved, saveSoundEffect, removeSavedSound } = get();
if (isSoundSaved({ soundId: soundEffect.id })) {
await removeSavedSound({ soundId: soundEffect.id });
} else {
await saveSoundEffect({ soundEffect });
}
},
clearSavedSounds: async () => {
try {
await storageService.clearSavedSounds();
set({
savedSounds: [],
savedSoundsError: null,
});
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Failed to clear saved sounds";
set({ savedSoundsError: errorMessage });
toast.error("Failed to clear saved sounds");
console.error("Failed to clear saved sounds:", error);
}
},
addSoundToTimeline: async ({ sound }) => {
const audioUrl = sound.previewUrl;
if (!audioUrl) {
toast.error("Sound file not available");
return false;
}
try {
const editor = EditorCore.getInstance();
const currentTime = editor.playback.getCurrentTime();
const tracks = editor.timeline.getTracks();
const response = await fetch(audioUrl);
if (!response.ok)
throw new Error(`Failed to download audio: ${response.statusText}`);
const arrayBuffer = await response.arrayBuffer();
const audioContext = new AudioContext();
const buffer = await audioContext.decodeAudioData(arrayBuffer);
const audioTrack = tracks.find((t) => t.type === "audio");
let trackId: string;
if (audioTrack) {
trackId = audioTrack.id;
} else {
trackId = editor.timeline.addTrack({ type: "audio" });
}
const element = buildLibraryAudioElement({
sourceUrl: audioUrl,
name: sound.name,
duration: sound.duration,
startTime: currentTime,
buffer,
});
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element,
});
return true;
} catch (error) {
console.error("Failed to add sound to timeline:", error);
toast.error(
error instanceof Error
? error.message
: "Failed to add sound to timeline",
{ id: `sound-${sound.id}` },
);
return false;
}
},
}));
import { create } from "zustand";
import type { SoundEffect, SavedSound } from "@/types/sounds";
import { storageService } from "@/services/storage/storage-service";
import { toast } from "sonner";
import { EditorCore } from "@/core";
import { buildLibraryAudioElement } from "@/lib/timeline/element-utils";
interface SoundsStore {
topSoundEffects: SoundEffect[];
isLoading: boolean;
error: string | null;
hasLoaded: boolean;
showCommercialOnly: boolean;
toggleCommercialFilter: () => void;
searchQuery: string;
searchResults: SoundEffect[];
isSearching: boolean;
searchError: string | null;
lastSearchQuery: string;
scrollPosition: number;
currentPage: number;
hasNextPage: boolean;
totalCount: number;
isLoadingMore: boolean;
savedSounds: SavedSound[];
isSavedSoundsLoaded: boolean;
isLoadingSavedSounds: boolean;
savedSoundsError: string | null;
addSoundToTimeline: ({ sound }: { sound: SoundEffect }) => Promise<boolean>;
setTopSoundEffects: ({ sounds }: { sounds: SoundEffect[] }) => void;
setLoading: ({ loading }: { loading: boolean }) => void;
setError: ({ error }: { error: string | null }) => void;
setHasLoaded: ({ loaded }: { loaded: boolean }) => void;
setSearchQuery: ({ query }: { query: string }) => void;
setSearchResults: ({ results }: { results: SoundEffect[] }) => void;
setSearching: ({ searching }: { searching: boolean }) => void;
setSearchError: ({ error }: { error: string | null }) => void;
setLastSearchQuery: ({ query }: { query: string }) => void;
setScrollPosition: ({ position }: { position: number }) => void;
setCurrentPage: ({ page }: { page: number }) => void;
setHasNextPage: ({ hasNext }: { hasNext: boolean }) => void;
setTotalCount: ({ count }: { count: number }) => void;
setLoadingMore: ({ loading }: { loading: boolean }) => void;
appendSearchResults: ({ results }: { results: SoundEffect[] }) => void;
appendTopSounds: ({ results }: { results: SoundEffect[] }) => void;
resetPagination: () => void;
loadSavedSounds: () => Promise<void>;
saveSoundEffect: ({
soundEffect,
}: {
soundEffect: SoundEffect;
}) => Promise<void>;
removeSavedSound: ({ soundId }: { soundId: number }) => Promise<void>;
isSoundSaved: ({ soundId }: { soundId: number }) => boolean;
toggleSavedSound: ({
soundEffect,
}: {
soundEffect: SoundEffect;
}) => Promise<void>;
clearSavedSounds: () => Promise<void>;
}
export const useSoundsStore = create<SoundsStore>((set, get) => ({
topSoundEffects: [],
isLoading: false,
error: null,
hasLoaded: false,
showCommercialOnly: true,
toggleCommercialFilter: () => {
set((state) => ({ showCommercialOnly: !state.showCommercialOnly }));
},
searchQuery: "",
searchResults: [],
isSearching: false,
searchError: null,
lastSearchQuery: "",
scrollPosition: 0,
currentPage: 1,
hasNextPage: false,
totalCount: 0,
isLoadingMore: false,
savedSounds: [],
isSavedSoundsLoaded: false,
isLoadingSavedSounds: false,
savedSoundsError: null,
setTopSoundEffects: ({ sounds }) => set({ topSoundEffects: sounds }),
setLoading: ({ loading }) => set({ isLoading: loading }),
setError: ({ error }) => set({ error }),
setHasLoaded: ({ loaded }) => set({ hasLoaded: loaded }),
setSearchQuery: ({ query }) => set({ searchQuery: query }),
setSearchResults: ({ results }) =>
set({ searchResults: results, currentPage: 1 }),
setSearching: ({ searching }) => set({ isSearching: searching }),
setSearchError: ({ error }) => set({ searchError: error }),
setLastSearchQuery: ({ query }) => set({ lastSearchQuery: query }),
setScrollPosition: ({ position }) => set({ scrollPosition: position }),
setCurrentPage: ({ page }) => set({ currentPage: page }),
setHasNextPage: ({ hasNext }) => set({ hasNextPage: hasNext }),
setTotalCount: ({ count }) => set({ totalCount: count }),
setLoadingMore: ({ loading }) => set({ isLoadingMore: loading }),
appendSearchResults: ({ results }) =>
set((state) => ({
searchResults: [...state.searchResults, ...results],
})),
appendTopSounds: ({ results }) =>
set((state) => ({
topSoundEffects: [...state.topSoundEffects, ...results],
})),
resetPagination: () =>
set({
currentPage: 1,
hasNextPage: false,
totalCount: 0,
isLoadingMore: false,
}),
loadSavedSounds: async () => {
if (get().isSavedSoundsLoaded) return;
try {
set({ isLoadingSavedSounds: true, savedSoundsError: null });
const savedSoundsData = await storageService.loadSavedSounds();
set({
savedSounds: savedSoundsData.sounds,
isSavedSoundsLoaded: true,
isLoadingSavedSounds: false,
});
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Failed to load saved sounds";
set({
savedSoundsError: errorMessage,
isLoadingSavedSounds: false,
});
console.error("Failed to load saved sounds:", error);
}
},
saveSoundEffect: async ({ soundEffect }) => {
try {
await storageService.saveSoundEffect({ soundEffect });
const savedSoundsData = await storageService.loadSavedSounds();
set({ savedSounds: savedSoundsData.sounds });
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Failed to save sound";
set({ savedSoundsError: errorMessage });
toast.error("Failed to save sound");
console.error("Failed to save sound:", error);
}
},
removeSavedSound: async ({ soundId }) => {
try {
await storageService.removeSavedSound({ soundId });
set((state) => ({
savedSounds: state.savedSounds.filter((sound) => sound.id !== soundId),
}));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Failed to remove sound";
set({ savedSoundsError: errorMessage });
toast.error("Failed to remove sound");
console.error("Failed to remove sound:", error);
}
},
isSoundSaved: ({ soundId }) => {
const { savedSounds } = get();
return savedSounds.some((sound) => sound.id === soundId);
},
toggleSavedSound: async ({ soundEffect }) => {
const { isSoundSaved, saveSoundEffect, removeSavedSound } = get();
if (isSoundSaved({ soundId: soundEffect.id })) {
await removeSavedSound({ soundId: soundEffect.id });
} else {
await saveSoundEffect({ soundEffect });
}
},
clearSavedSounds: async () => {
try {
await storageService.clearSavedSounds();
set({
savedSounds: [],
savedSoundsError: null,
});
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : "Failed to clear saved sounds";
set({ savedSoundsError: errorMessage });
toast.error("Failed to clear saved sounds");
console.error("Failed to clear saved sounds:", error);
}
},
addSoundToTimeline: async ({ sound }) => {
const audioUrl = sound.previewUrl;
if (!audioUrl) {
toast.error("Sound file not available");
return false;
}
try {
const editor = EditorCore.getInstance();
const currentTime = editor.playback.getCurrentTime();
const tracks = editor.timeline.getTracks();
const response = await fetch(audioUrl);
if (!response.ok)
throw new Error(`Failed to download audio: ${response.statusText}`);
const arrayBuffer = await response.arrayBuffer();
const audioContext = new AudioContext();
const buffer = await audioContext.decodeAudioData(arrayBuffer);
const audioTrack = tracks.find((t) => t.type === "audio");
let trackId: string;
if (audioTrack) {
trackId = audioTrack.id;
} else {
trackId = editor.timeline.addTrack({ type: "audio" });
}
const element = buildLibraryAudioElement({
sourceUrl: audioUrl,
name: sound.name,
duration: sound.duration,
startTime: currentTime,
buffer,
});
editor.timeline.insertElement({
placement: { mode: "explicit", trackId },
element,
});
return true;
} catch (error) {
console.error("Failed to add sound to timeline:", error);
toast.error(
error instanceof Error
? error.message
: "Failed to add sound to timeline",
{ id: `sound-${sound.id}` },
);
return false;
}
},
}));
+33 -33
View File
@@ -1,33 +1,33 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type TextPropertiesTab = "text" | "transform";
export interface TextPropertiesTabMeta {
value: TextPropertiesTab;
label: string;
}
export const TEXT_PROPERTIES_TABS: ReadonlyArray<TextPropertiesTabMeta> = [
{ value: "text", label: "Text" },
{ value: "transform", label: "Transform" },
] as const;
export function isTextPropertiesTab(value: string): value is TextPropertiesTab {
return TEXT_PROPERTIES_TABS.some((t) => t.value === value);
}
interface TextPropertiesState {
activeTab: TextPropertiesTab;
setActiveTab: (tab: TextPropertiesTab) => void;
}
export const useTextPropertiesStore = create<TextPropertiesState>()(
persist(
(set) => ({
activeTab: "text",
setActiveTab: (tab) => set({ activeTab: tab }),
}),
{ name: "text-properties" },
),
);
import { create } from "zustand";
import { persist } from "zustand/middleware";
export type TextPropertiesTab = "text" | "transform";
export interface TextPropertiesTabMeta {
value: TextPropertiesTab;
label: string;
}
export const TEXT_PROPERTIES_TABS: ReadonlyArray<TextPropertiesTabMeta> = [
{ value: "text", label: "Text" },
{ value: "transform", label: "Transform" },
] as const;
export function isTextPropertiesTab(value: string): value is TextPropertiesTab {
return TEXT_PROPERTIES_TABS.some((t) => t.value === value);
}
interface TextPropertiesState {
activeTab: TextPropertiesTab;
setActiveTab: (tab: TextPropertiesTab) => void;
}
export const useTextPropertiesStore = create<TextPropertiesState>()(
persist(
(set) => ({
activeTab: "text",
setActiveTab: (tab) => set({ activeTab: tab }),
}),
{ name: "text-properties" },
),
);
+39 -39
View File
@@ -1,39 +1,39 @@
export interface SoundEffect {
id: number;
name: string;
description: string;
url: string;
previewUrl?: string;
downloadUrl?: string;
duration: number;
filesize: number;
type: string;
channels: number;
bitrate: number;
bitdepth: number;
samplerate: number;
username: string;
tags: string[];
license: string;
created: string;
downloads: number;
rating: number;
ratingCount: number;
}
export interface SavedSound {
id: number; // freesound id
name: string;
username: string;
previewUrl?: string;
downloadUrl?: string;
duration: number;
tags: string[];
license: string;
savedAt: string; // iso date string
}
export interface SavedSoundsData {
sounds: SavedSound[];
lastModified: string;
}
export interface SoundEffect {
id: number;
name: string;
description: string;
url: string;
previewUrl?: string;
downloadUrl?: string;
duration: number;
filesize: number;
type: string;
channels: number;
bitrate: number;
bitdepth: number;
samplerate: number;
username: string;
tags: string[];
license: string;
created: string;
downloads: number;
rating: number;
ratingCount: number;
}
export interface SavedSound {
id: number; // freesound id
name: string;
username: string;
previewUrl?: string;
downloadUrl?: string;
duration: number;
tags: string[];
license: string;
savedAt: string; // iso date string
}
export interface SavedSoundsData {
sounds: SavedSound[];
lastModified: string;
}
+243
View File
@@ -0,0 +1,243 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Context Menu Designs</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap');
body { font-family: 'Inter', sans-serif; }
:root {
--brand-blue: #00A3FF;
}
/* Utility to hide/show */
.context-menu { display: none; }
.context-menu.open { display: block; }
</style>
</head>
<body class="bg-[#ffffff] text-slate-900 min-h-screen p-12">
<div class="max-w-6xl mx-auto space-y-4 mb-12 text-center">
<h1 class="text-2xl font-bold">Context Menu Explorations</h1>
<p class="text-slate-500">Right-click simulations (right-click buttons to toggle).</p>
<div class="flex items-center justify-center gap-4 mt-8">
<button oncontextmenu="event.preventDefault(); toggle(1)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm transition-colors">
Option 1: Standard
</button>
<button oncontextmenu="event.preventDefault(); toggle(2)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm transition-colors">
Option 2: Rich with Shortcuts
</button>
<button oncontextmenu="event.preventDefault(); toggle(3)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm transition-colors">
Option 3: Dense & Technical
</button>
<button oncontextmenu="event.preventDefault(); toggle(4)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm transition-colors">
Option 4: Dense & Technical (Icons)
</button>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 items-start max-w-7xl mx-auto relative h-[500px]">
<!-- Option 1: Standard -->
<!-- Characteristics: Simple, clean, standard spacing -->
<div id="menu-1" class="context-menu absolute top-0 left-[15%] -translate-x-1/2 w-64 bg-white rounded-[20px] shadow-xl border border-slate-200 overflow-hidden py-2 z-10">
<div class="flex flex-col gap-0.5">
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-600 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center gap-3 group">
<i data-lucide="folder-open" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
<span>Open</span>
</a>
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-600 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center gap-3 group">
<i data-lucide="edit-2" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
<span>Rename</span>
</a>
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-600 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center gap-3 group">
<i data-lucide="copy" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
<span>Duplicate</span>
</a>
<div class="h-px bg-slate-100 my-1 mx-2"></div>
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-600 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center gap-3 group">
<i data-lucide="share" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
<span>Share...</span>
</a>
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-600 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center gap-3 group">
<i data-lucide="download" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
<span>Download</span>
</a>
<div class="h-px bg-slate-100 my-1 mx-2"></div>
<a href="#" class="block px-4 py-2 mx-2 text-sm text-red-500 rounded-xl hover:bg-red-50 flex items-center gap-3 group">
<i data-lucide="trash-2" class="w-4 h-4 group-hover:text-red-600"></i>
<span>Delete</span>
</a>
</div>
</div>
<!-- Option 2: Rich with Shortcuts -->
<!-- Characteristics: Wider, right-aligned shortcuts, grouping -->
<div id="menu-2" class="context-menu absolute top-0 left-[38%] -translate-x-1/2 w-72 bg-white rounded-[20px] shadow-xl border border-slate-200 overflow-hidden py-2 z-10">
<div class="flex flex-col gap-0.5">
<div class="px-4 py-1.5 text-[11px] font-bold uppercase tracking-wider text-slate-400 select-none">
Actions
</div>
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-700 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center justify-between group">
<div class="flex items-center gap-3">
<i data-lucide="scissors" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
<span>Cut</span>
</div>
<span class="text-xs text-slate-400 group-hover:text-blue-300">⌘X</span>
</a>
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-700 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center justify-between group">
<div class="flex items-center gap-3">
<i data-lucide="copy" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
<span>Copy</span>
</div>
<span class="text-xs text-slate-400 group-hover:text-blue-300">⌘C</span>
</a>
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-700 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center justify-between group">
<div class="flex items-center gap-3">
<i data-lucide="clipboard" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
<span>Paste</span>
</div>
<span class="text-xs text-slate-400 group-hover:text-blue-300">⌘V</span>
</a>
<div class="h-px bg-slate-100 my-1 mx-2"></div>
<a href="#" class="block px-4 py-2 mx-2 text-sm text-slate-700 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center justify-between group">
<div class="flex items-center gap-3">
<i data-lucide="maximize" class="w-4 h-4 text-slate-400 group-hover:text-[#00A3FF]"></i>
<span>Properties</span>
</div>
<span class="text-xs text-slate-400 group-hover:text-blue-300">⌘I</span>
</a>
</div>
</div>
<!-- Option 3: Dense & Technical -->
<!-- Characteristics: Smaller text, smaller padding, minimal radius, efficient -->
<div id="menu-3" class="context-menu absolute top-0 left-[62%] -translate-x-1/2 w-60 bg-white rounded-xl shadow-xl border border-slate-200 overflow-hidden py-2.5 z-10">
<div class="flex flex-col">
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between group">
<span>New Folder</span>
</a>
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between group">
<span>New File</span>
</a>
<div class="h-px bg-slate-100 my-2"></div>
<div class="relative group/submenu">
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between">
<span>Sort By</span>
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-slate-400"></i>
</a>
</div>
<div class="relative group/submenu">
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between">
<span>View As</span>
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-slate-400"></i>
</a>
</div>
<div class="h-px bg-slate-100 my-2"></div>
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between group">
<span>Refresh</span>
<span class="text-xs text-slate-400">R</span>
</a>
</div>
</div>
<!-- Option 4: Dense & Technical (Icons) -->
<div id="menu-4" class="context-menu absolute top-0 left-[85%] -translate-x-1/2 w-60 bg-white rounded-xl shadow-xl border border-slate-200 overflow-hidden py-2.5 z-10">
<div class="flex flex-col">
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between group">
<div class="flex items-center gap-2.5">
<i data-lucide="folder-plus" class="w-4 h-4 text-slate-400 group-hover:text-slate-600"></i>
<span>New Folder</span>
</div>
</a>
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between group">
<div class="flex items-center gap-2.5">
<i data-lucide="file-plus" class="w-4 h-4 text-slate-400 group-hover:text-slate-600"></i>
<span>New File</span>
</div>
</a>
<div class="h-px bg-slate-100 my-2"></div>
<div class="relative group/submenu">
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between">
<div class="flex items-center gap-2.5">
<i data-lucide="arrow-up-down" class="w-4 h-4 text-slate-400"></i>
<span>Sort By</span>
</div>
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-slate-400"></i>
</a>
</div>
<div class="relative group/submenu">
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between">
<div class="flex items-center gap-2.5">
<i data-lucide="layout-grid" class="w-4 h-4 text-slate-400"></i>
<span>View As</span>
</div>
<i data-lucide="chevron-right" class="w-3.5 h-3.5 text-slate-400"></i>
</a>
</div>
<div class="h-px bg-slate-100 my-2"></div>
<a href="#" class="block px-4 py-1.5 text-sm font-medium text-slate-700 hover:bg-slate-50 flex items-center justify-between group">
<div class="flex items-center gap-2.5">
<i data-lucide="rotate-cw" class="w-4 h-4 text-slate-400 group-hover:text-slate-600"></i>
<span>Refresh</span>
</div>
<span class="text-xs text-slate-400">R</span>
</a>
</div>
</div>
</div>
<script>
lucide.createIcons();
function toggle(id) {
// Close others
[1, 2, 3, 4].forEach(num => {
if (num !== id) {
document.getElementById(`menu-${num}`).classList.remove('open');
}
});
// Toggle current
const menu = document.getElementById(`menu-${id}`);
if (menu) {
menu.classList.toggle('open');
}
}
// Close when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('button') && !e.target.closest('.context-menu')) {
document.querySelectorAll('.context-menu').forEach(el => el.classList.remove('open'));
}
});
// Open option 2 by default
toggle(2);
</script>
</body>
</html>
+566
View File
@@ -0,0 +1,566 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Projects - Brand Design</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap");
body {
font-family: "Inter", sans-serif;
}
/* Brand Colors */
:root {
--brand-blue: #00a3ff;
}
.project-card:hover .card-actions {
opacity: 1;
}
.checkbox-wrapper:checked + div {
border-color: var(--brand-blue);
background-color: #eff6ff;
}
.controls-row.has-selection .controls-select {
opacity: 1;
}
.project-select-wrapper {
opacity: 0;
}
.project-card:hover .project-select-wrapper {
opacity: 1;
}
.project-card:has(.project-select:checked) .project-select-wrapper {
opacity: 1;
}
.multi-select .project-menu {
opacity: 0 !important;
pointer-events: none;
}
/* Custom Checkbox - Softer and Rounder */
.custom-checkbox {
appearance: none;
background-color: #fff;
border: 2px solid #e2e8f0;
border-radius: 8px; /* Rounder checkbox */
display: inline-grid;
place-content: center;
}
.custom-checkbox:checked {
background-color: var(--brand-blue);
border-color: var(--brand-blue);
}
.custom-checkbox:checked::after {
content: "";
width: 10px;
height: 10px;
background-color: #fff;
clip-path: polygon(
14% 44%,
0 58%,
40% 100%,
100% 24%,
86% 10%,
40% 70%
);
}
</style>
</head>
<body class="bg-[#ffffff] text-slate-900 h-screen flex overflow-hidden">
<!-- Sidebar -->
<aside
class="w-64 bg-white border-r border-slate-100 flex flex-col flex-shrink-0"
>
<div class="p-6 pl-8 flex items-center gap-3">
<img
src="../apps/web/public/logos/opencut/1k/logo-black.png"
alt="OpenCut Logo"
class="size-6"
/>
<span class="font-semibold text-lg tracking-tight text-slate-900"
>OpenCut</span
>
</div>
<nav class="flex-1 px-4 space-y-2 mt-2">
<a
href="#"
class="flex items-center gap-3 px-4 py-3 text-sm font-medium bg-blue-50/50 text-[#00A3FF] rounded-2xl"
>
<i data-lucide="layout-grid" class="w-5 h-5"></i>
Projects
</a>
<div class="pt-6 pb-2 px-4">
<p class="text-xs font-bold text-slate-400 uppercase tracking-wider">
Folders
</p>
</div>
<a
href="#"
class="flex items-center gap-3 px-4 py-3 text-sm font-medium text-slate-500 hover:bg-slate-50 hover:text-slate-900 rounded-2xl"
>
<i data-lucide="folder" class="w-5 h-5 text-slate-400"></i>
Marketing
</a>
<a
href="#"
class="flex items-center gap-3 px-4 py-3 text-sm font-medium text-slate-500 hover:bg-slate-50 hover:text-slate-900 rounded-2xl"
>
<i data-lucide="folder" class="w-5 h-5 text-slate-400"></i>
Social media
</a>
</nav>
</aside>
<!-- Main Content -->
<main class="flex-1 flex flex-col min-w-0">
<!-- Header -->
<header class="h-20 flex items-center justify-between px-8 flex-shrink-0">
<div class="flex items-center gap-4">
<h1 class="text-xl font-bold text-slate-900">All projects</h1>
</div>
<div class="flex items-center gap-4">
<div class="relative group">
<i
data-lucide="search"
class="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400"
></i>
<input
id="search-input"
type="text"
placeholder="Search..."
class="w-64 bg-white border border-slate-200 text-slate-900 text-sm rounded-full pl-10 pr-4 py-2.5 focus:outline-none focus:border-[#00A3FF] focus:ring-4 focus:ring-blue-500/10 placeholder:text-slate-400 shadow-sm"
/>
</div>
<div
class="flex bg-white rounded-full border border-slate-200 p-1 shadow-sm"
>
<button
class="p-2 rounded-full bg-slate-100 text-slate-900 shadow-sm"
>
<i data-lucide="layout-grid" class="w-4 h-4"></i>
</button>
<button
class="p-2 rounded-full text-slate-400 hover:text-slate-600"
>
<i data-lucide="list" class="w-4 h-4"></i>
</button>
</div>
<button
class="bg-[#00A3FF] hover:bg-[#008BE0] text-white px-5 py-2.5 rounded-full text-sm font-semibold flex items-center gap-2 active:scale-95"
>
<i data-lucide="plus" class="w-4 h-4"></i>
New project
</button>
</div>
</header>
<!-- Content Area -->
<div class="flex-1 overflow-y-auto px-8 pb-8">
<!-- Controls Row (Select all + Sort) -->
<div
id="controls-row"
class="controls-row group mb-4 flex items-center gap-3 text-slate-700"
>
<div class="flex items-center gap-2">
<label
class="flex items-center gap-3 text-sm font-medium cursor-pointer select-none p-1 rounded-lg"
>
<input
id="select-all"
type="checkbox"
class="custom-checkbox controls-select size-5"
/>
<span class="text-slate-500 group-hover:text-slate-700"
>Select all</span
>
</label>
</div>
<div class="h-4 w-px bg-slate-200 mx-2"></div>
<button
type="button"
class="inline-flex items-center gap-1.5 rounded-lg p-1 text-sm font-medium text-slate-500 hover:text-slate-900"
>
Name
<i data-lucide="arrow-down" class="size-3.5"></i>
</button>
</div>
<!-- Grid -->
<div
id="projects-grid"
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8"
>
<!-- Project Card 1 -->
<div
class="project-card group rounded-[24px] overflow-hidden relative h-auto flex flex-col"
>
<!-- Selection Checkbox -->
<div class="project-select-wrapper absolute top-4 left-4 z-20">
<input
type="checkbox"
class="custom-checkbox project-select w-6 h-6 cursor-pointer shadow-md"
onchange="toggleSelection(this)"
/>
</div>
<!-- Menu -->
<div
class="project-menu absolute top-4 right-4 z-20 opacity-0 group-hover:opacity-100"
>
<button
class="p-2 bg-white/90 backdrop-blur-sm rounded-full shadow-sm hover:bg-white text-slate-600 hover:text-[#00A3FF]"
>
<i data-lucide="more-horizontal" class="w-4 h-4"></i>
</button>
</div>
<!-- Thumbnail -->
<div
class="aspect-video bg-slate-50 relative overflow-hidden m-1 rounded-lg"
>
<img
src="C:\Users\heart\Downloads\download.jpg"
class="w-full h-full object-cover"
alt="Project"
/>
<div
class="absolute bottom-2 right-2 bg-black/60 text-white text-[11px] font-semibold px-2 py-1 rounded-lg"
>
02:14
</div>
</div>
<!-- Info -->
<div class="px-2 pb-5 pt-3 flex-1 flex flex-col justify-between">
<div>
<h3 class="font-semibold text-slate-900 truncate text-[15px]">
Summer Vlog 2025
</h3>
<div class="flex items-center gap-2 mt-2 text-slate-400">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="size-4"
width="24"
height="24"
color="currentColor"
fill="none"
stroke="currentColor"
stroke-width="1.5"
className=""
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8V12L14 14" />
</svg>
<p class="text-xs font-medium">Created Jan 20, 2026</p>
</div>
</div>
</div>
</div>
<!-- Project Card 2 -->
<div
class="project-card group rounded-[24px] overflow-hidden relative h-auto flex flex-col"
>
<!-- Selection Checkbox -->
<div class="project-select-wrapper absolute top-4 left-4 z-20">
<input
type="checkbox"
class="custom-checkbox project-select w-6 h-6 cursor-pointer shadow-md"
onchange="toggleSelection(this)"
/>
</div>
<!-- Menu -->
<div
class="project-menu absolute top-4 right-4 z-20 opacity-0 group-hover:opacity-100"
>
<button
class="p-2 bg-white/90 backdrop-blur-sm rounded-full shadow-sm hover:bg-white text-slate-600 hover:text-[#00A3FF]"
>
<i data-lucide="more-horizontal" class="w-4 h-4"></i>
</button>
</div>
<div
class="aspect-video bg-slate-50 relative overflow-hidden m-1 rounded-lg"
>
<img
src="C:\Users\heart\Downloads\download.jpg"
class="w-full h-full object-cover"
alt="Project"
/>
<div
class="absolute bottom-2 right-2 bg-black/60 text-white text-[11px] font-semibold px-2 py-1 rounded-lg"
>
00:45
</div>
</div>
<div class="px-2 pb-5 pt-3 flex-1 flex flex-col justify-between">
<div>
<h3 class="font-semibold text-slate-900 truncate text-[15px]">
Product Launch Teaser
</h3>
<div class="flex items-center gap-2 mt-2 text-slate-400">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="size-4"
width="24"
height="24"
color="currentColor"
fill="none"
stroke="currentColor"
stroke-width="1.5"
className=""
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8V12L14 14" />
</svg>
<p class="text-xs font-medium">Created Jan 20, 2026</p>
</div>
</div>
</div>
</div>
<!-- Project Card 3 -->
<div
class="project-card group rounded-[24px] overflow-hidden relative h-auto flex flex-col"
>
<!-- Selection Checkbox -->
<div class="project-select-wrapper absolute top-4 left-4 z-20">
<input
type="checkbox"
class="custom-checkbox project-select w-6 h-6 cursor-pointer shadow-md"
onchange="toggleSelection(this)"
/>
</div>
<!-- Menu -->
<div
class="project-menu absolute top-4 right-4 z-20 opacity-0 group-hover:opacity-100"
>
<button
class="p-2 bg-white/90 backdrop-blur-sm rounded-full shadow-sm hover:bg-white text-slate-600 hover:text-[#00A3FF]"
>
<i data-lucide="more-horizontal" class="w-4 h-4"></i>
</button>
</div>
<div
class="aspect-video bg-slate-50 relative overflow-hidden m-1 rounded-lg"
>
<img
src="C:\Users\heart\Downloads\download.jpg"
class="w-full h-full object-cover"
alt="Project"
/>
<div
class="absolute bottom-2 right-2 bg-black/60 text-white text-[11px] font-semibold px-2 py-1 rounded-lg"
>
03:30
</div>
</div>
<div class="px-2 pb-5 pt-3 flex-1 flex flex-col justify-between">
<div>
<h3 class="font-semibold text-slate-900 truncate text-[15px]">
Podcast Ep. 4
</h3>
<div class="flex items-center gap-2 mt-2 text-slate-400">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
class="size-4"
width="24"
height="24"
color="currentColor"
fill="none"
stroke="currentColor"
stroke-width="1.5"
className=""
>
<circle cx="12" cy="12" r="10" />
<path d="M12 8V12L14 14" />
</svg>
<p class="text-xs font-medium">Created Jan 18, 2026</p>
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div
id="empty-state"
class="hidden flex flex-col items-center justify-center py-24 text-center"
>
<div
class="w-16 h-16 bg-slate-50 rounded-2xl flex items-center justify-center mb-6 ring-1 ring-slate-900/5"
>
<i data-lucide="search" class="w-8 h-8 text-slate-400"></i>
</div>
<h3 class="text-slate-900 font-semibold text-lg mb-2">
No projects found
</h3>
<p
class="text-slate-500 text-sm max-w-[280px] mx-auto leading-relaxed mb-8"
>
We couldn't find any projects matching your search. Try adjusting
your terms.
</p>
<button
onclick="clearSearch()"
class="inline-flex items-center justify-center px-6 py-2.5 text-sm font-medium text-slate-700 bg-white border border-slate-200 rounded-full hover:bg-slate-50 hover:text-slate-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-slate-200"
>
Clear search
</button>
</div>
</div>
</main>
<script>
lucide.createIcons();
const selectedProjectIds = new Set();
function getProjectId({ card, index }) {
return card.getAttribute("data-project-id") ?? `project-${index}`;
}
function updateControlsSelectionState() {
const controlsRow = document.getElementById("controls-row");
const selectAll = document.getElementById("select-all");
const projectCheckboxes = document.querySelectorAll(".project-select");
if (!controlsRow || !selectAll) {
return;
}
const selectedCount = selectedProjectIds.size;
document.body.classList.toggle("multi-select", selectedCount > 1);
controlsRow.classList.toggle("has-selection", selectedCount > 0);
const totalCount = projectCheckboxes.length;
selectAll.checked = selectedCount > 0 && selectedCount === totalCount;
selectAll.indeterminate =
selectedCount > 0 && selectedCount < totalCount;
}
function syncSelectionState() {
const projectCards = Array.from(
document.querySelectorAll(".project-card"),
);
for (const [index, card] of projectCards.entries()) {
const checkbox = card.querySelector(".project-select");
if (!checkbox) {
continue;
}
const projectId = getProjectId({ card, index });
const isSelected = selectedProjectIds.has(projectId);
checkbox.checked = isSelected;
}
updateControlsSelectionState();
}
function toggleSelection(checkbox) {
const card = checkbox.closest(".project-card");
if (!card) {
return;
}
const projectCards = Array.from(
document.querySelectorAll(".project-card"),
);
const projectId = getProjectId({
card,
index: projectCards.indexOf(card),
});
if (checkbox.checked) {
selectedProjectIds.add(projectId);
} else {
selectedProjectIds.delete(projectId);
}
syncSelectionState();
}
function toggleSelectAll({ isSelected }) {
const projectCards = Array.from(
document.querySelectorAll(".project-card"),
);
if (!isSelected) {
selectedProjectIds.clear();
syncSelectionState();
return;
}
for (const [index, card] of projectCards.entries()) {
selectedProjectIds.add(getProjectId({ card, index }));
}
syncSelectionState();
}
document.addEventListener("DOMContentLoaded", () => {
const selectAll = document.getElementById("select-all");
if (selectAll) {
selectAll.addEventListener("change", () => {
toggleSelectAll({ isSelected: selectAll.checked });
});
}
syncSelectionState();
// Search functionality
const searchInput = document.getElementById("search-input");
const emptyState = document.getElementById("empty-state");
const projectsGrid = document.getElementById("projects-grid");
if (searchInput) {
searchInput.addEventListener("input", (e) => {
const query = e.target.value.toLowerCase().trim();
const cards = document.querySelectorAll(".project-card");
let hasVisibleProjects = false;
cards.forEach((card) => {
const titleElement = card.querySelector("h3");
if (!titleElement) return;
const title = titleElement.textContent.toLowerCase();
const isMatch = title.includes(query);
if (isMatch) {
card.classList.remove("hidden");
hasVisibleProjects = true;
} else {
card.classList.add("hidden");
}
});
// Toggle empty state
if (hasVisibleProjects) {
emptyState.classList.add("hidden");
projectsGrid.classList.remove("hidden");
} else {
emptyState.classList.remove("hidden");
projectsGrid.classList.add("hidden");
}
// Re-initialize icons for new elements (if any dynamic ones were added, though here we just hide/show)
// But importantly, the empty state icon needs to be rendered if it wasn't visible initially
lucide.createIcons();
});
}
// Expose clearSearch globally
window.clearSearch = function () {
if (searchInput) {
searchInput.value = "";
searchInput.dispatchEvent(new Event("input"));
searchInput.focus();
}
};
});
</script>
</body>
</html>
+301
View File
@@ -0,0 +1,301 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Projects - Design 2</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Inter:wght@300;400;500;600&display=swap');
body { font-family: 'Inter', sans-serif; background-color: #09090b; color: #e4e4e7; }
.font-mono { font-family: 'JetBrains Mono', monospace; }
/* Custom Scrollbar */
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: #09090b; }
::-webkit-scrollbar-thumb { background: #27272a; border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: #3f3f46; }
</style>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
zinc: {
850: '#1f1f22',
900: '#18181b',
950: '#09090b',
}
}
}
}
}
</script>
</head>
<body class="h-screen flex overflow-hidden selection:bg-indigo-500/30">
<!-- Sidebar -->
<aside class="w-72 bg-zinc-950 border-r border-zinc-800 flex flex-col flex-shrink-0">
<!-- Brand -->
<div class="h-14 flex items-center px-4 border-b border-zinc-900">
<div class="flex items-center gap-2 text-zinc-100">
<div class="w-6 h-6 bg-indigo-600 rounded flex items-center justify-center">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
</div>
<span class="font-bold tracking-tight">STUDIO</span>
<span class="text-xs font-mono text-zinc-500 ml-auto">v2.4</span>
</div>
</div>
<!-- User -->
<div class="p-3">
<button class="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-zinc-900 transition-colors text-left group">
<div class="w-8 h-8 rounded bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-xs font-bold text-white shadow-lg shadow-indigo-900/20">
JD
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-zinc-200 truncate">John Doe</div>
<div class="text-xs text-zinc-500 truncate group-hover:text-zinc-400">Pro Plan</div>
</div>
<i data-lucide="chevron-down" class="w-4 h-4 text-zinc-600"></i>
</button>
</div>
<!-- Nav -->
<nav class="flex-1 overflow-y-auto px-3 py-2 space-y-6">
<div>
<div class="text-[10px] font-bold text-zinc-600 uppercase tracking-wider mb-2 px-2">Workspace</div>
<div class="space-y-0.5">
<a href="#" class="flex items-center gap-2 px-2 py-1.5 text-sm font-medium bg-zinc-900 text-indigo-400 rounded-md border border-zinc-800/50">
<i data-lucide="grid" class="w-4 h-4"></i>
Projects
</a>
<a href="#" class="flex items-center gap-2 px-2 py-1.5 text-sm font-medium text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900/50 rounded-md transition-colors">
<i data-lucide="film" class="w-4 h-4"></i>
Media Library
</a>
<a href="#" class="flex items-center gap-2 px-2 py-1.5 text-sm font-medium text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900/50 rounded-md transition-colors">
<i data-lucide="wand-2" class="w-4 h-4"></i>
Templates
</a>
</div>
</div>
<div>
<div class="flex items-center justify-between px-2 mb-2 group">
<div class="text-[10px] font-bold text-zinc-600 uppercase tracking-wider">Folders</div>
<button class="text-zinc-600 hover:text-zinc-300 opacity-0 group-hover:opacity-100 transition-opacity">
<i data-lucide="plus" class="w-3 h-3"></i>
</button>
</div>
<div class="space-y-0.5">
<a href="#" class="flex items-center gap-2 px-2 py-1.5 text-sm text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900/50 rounded-md transition-colors">
<i data-lucide="folder" class="w-4 h-4 text-zinc-700"></i>
Client Work
</a>
<a href="#" class="flex items-center gap-2 px-2 py-1.5 text-sm text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900/50 rounded-md transition-colors">
<i data-lucide="folder" class="w-4 h-4 text-purple-900/50"></i>
Personal
</a>
<a href="#" class="flex items-center gap-2 px-2 py-1.5 text-sm text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900/50 rounded-md transition-colors">
<i data-lucide="folder" class="w-4 h-4 text-zinc-700"></i>
Archive 2024
</a>
</div>
</div>
</nav>
<!-- Storage Status -->
<div class="p-4 border-t border-zinc-900">
<div class="flex items-center justify-between text-xs mb-2">
<span class="text-zinc-400">Storage</span>
<span class="text-zinc-500">75%</span>
</div>
<div class="h-1 bg-zinc-800 rounded-full overflow-hidden">
<div class="h-full bg-indigo-600 w-3/4"></div>
</div>
<div class="mt-2 text-[10px] text-zinc-600">15GB of 20GB used</div>
</div>
</aside>
<!-- Main -->
<main class="flex-1 flex flex-col min-w-0 bg-zinc-950">
<!-- Top Bar -->
<header class="h-14 border-b border-zinc-800 flex items-center justify-between px-6 bg-zinc-950/50 backdrop-blur sticky top-0 z-20">
<div class="flex items-center gap-4">
<h1 class="text-sm font-semibold text-zinc-100">All Projects</h1>
<div class="h-4 w-px bg-zinc-800"></div>
<div class="flex items-center gap-2 text-xs text-zinc-500">
<span>Sort by:</span>
<button class="flex items-center gap-1 text-zinc-300 hover:text-white">
Last Modified
<i data-lucide="chevron-down" class="w-3 h-3"></i>
</button>
</div>
</div>
<div class="flex items-center gap-3">
<div class="relative">
<i data-lucide="search" class="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-zinc-500"></i>
<input type="text" placeholder="Search..." class="w-64 bg-zinc-900 border border-zinc-800 text-zinc-300 text-sm rounded-md pl-9 pr-3 py-1.5 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/50 transition-all placeholder:text-zinc-600">
</div>
<div class="flex bg-zinc-900 rounded-md border border-zinc-800 p-0.5">
<button class="p-1.5 rounded bg-zinc-800 text-zinc-200 shadow-sm">
<i data-lucide="layout-grid" class="w-3.5 h-3.5"></i>
</button>
<button class="p-1.5 rounded text-zinc-500 hover:text-zinc-300">
<i data-lucide="list" class="w-3.5 h-3.5"></i>
</button>
</div>
<button class="bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-1.5 rounded-md text-sm font-medium flex items-center gap-2 transition-colors shadow-lg shadow-indigo-900/20">
<i data-lucide="plus" class="w-3.5 h-3.5"></i>
New
</button>
</div>
</header>
<!-- Content -->
<div class="flex-1 overflow-y-auto p-6">
<!-- Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
<!-- Project Card 1 -->
<div class="group relative bg-zinc-900 border border-zinc-800 hover:border-zinc-700 rounded-lg overflow-hidden transition-all hover:shadow-xl hover:shadow-black/50">
<!-- Hover Actions -->
<div class="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<button class="p-1.5 bg-black/80 backdrop-blur rounded text-zinc-400 hover:text-white hover:bg-black">
<i data-lucide="more-vertical" class="w-3.5 h-3.5"></i>
</button>
</div>
<!-- Selection -->
<div class="absolute top-2 left-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
<div class="bg-black/50 backdrop-blur rounded p-0.5">
<input type="checkbox" class="w-4 h-4 rounded border-zinc-600 bg-zinc-800 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-zinc-900">
</div>
</div>
<!-- Thumbnail -->
<div class="aspect-video bg-zinc-950 relative group-hover:opacity-90 transition-opacity">
<img src="https://images.unsplash.com/photo-1626814026160-2237a95fc5a0?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3" class="w-full h-full object-cover" alt="Project">
<!-- Progress Bar -->
<div class="absolute bottom-0 left-0 right-0 h-1 bg-zinc-800/50">
<div class="h-full bg-indigo-500 w-1/3"></div>
</div>
<div class="absolute bottom-2 right-2 bg-black/80 text-zinc-300 text-[10px] font-mono px-1.5 py-0.5 rounded border border-white/10">
1080p
</div>
</div>
<!-- Meta -->
<div class="p-3">
<div class="flex items-start justify-between gap-2">
<h3 class="text-sm font-medium text-zinc-200 truncate group-hover:text-white transition-colors">Cyberpunk Intro</h3>
<span class="text-[10px] text-zinc-500 font-mono whitespace-nowrap">00:15</span>
</div>
<div class="mt-2 flex items-center justify-between">
<div class="flex items-center gap-2 text-[11px] text-zinc-500">
<span class="w-2 h-2 rounded-full bg-green-500/20 border border-green-500/50"></span>
2h ago
</div>
<div class="flex -space-x-1.5">
<div class="w-5 h-5 rounded-full bg-zinc-800 border border-zinc-900 flex items-center justify-center text-[8px] text-zinc-400">JD</div>
</div>
</div>
</div>
</div>
<!-- Project Card 2 -->
<div class="group relative bg-zinc-900 border border-zinc-800 hover:border-zinc-700 rounded-lg overflow-hidden transition-all hover:shadow-xl hover:shadow-black/50">
<div class="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1">
<button class="p-1.5 bg-black/80 backdrop-blur rounded text-zinc-400 hover:text-white hover:bg-black">
<i data-lucide="more-vertical" class="w-3.5 h-3.5"></i>
</button>
</div>
<div class="aspect-video bg-zinc-950 relative group-hover:opacity-90 transition-opacity flex items-center justify-center">
<div class="absolute inset-0 bg-gradient-to-br from-zinc-800 to-zinc-900"></div>
<i data-lucide="clapperboard" class="w-8 h-8 text-zinc-700"></i>
<div class="absolute bottom-2 right-2 bg-black/80 text-zinc-300 text-[10px] font-mono px-1.5 py-0.5 rounded border border-white/10">
4K
</div>
</div>
<div class="p-3">
<div class="flex items-start justify-between gap-2">
<h3 class="text-sm font-medium text-zinc-200 truncate group-hover:text-white transition-colors">Documentary_Final_v2</h3>
<span class="text-[10px] text-zinc-500 font-mono whitespace-nowrap">45:20</span>
</div>
<div class="mt-2 flex items-center justify-between">
<div class="flex items-center gap-2 text-[11px] text-zinc-500">
<span class="w-2 h-2 rounded-full bg-yellow-500/20 border border-yellow-500/50"></span>
1d ago
</div>
<div class="flex -space-x-1.5">
<div class="w-5 h-5 rounded-full bg-zinc-800 border border-zinc-900 flex items-center justify-center text-[8px] text-zinc-400">JD</div>
<div class="w-5 h-5 rounded-full bg-indigo-900 border border-zinc-900 flex items-center justify-center text-[8px] text-indigo-200">AK</div>
</div>
</div>
</div>
</div>
<!-- Project Card 3 (Selected) -->
<div class="group relative bg-zinc-800/50 border border-indigo-500/50 rounded-lg overflow-hidden transition-all shadow-lg shadow-indigo-900/10">
<div class="absolute top-2 right-2 z-10 opacity-100 flex gap-1">
<button class="p-1.5 bg-black/80 backdrop-blur rounded text-zinc-400 hover:text-white hover:bg-black">
<i data-lucide="more-vertical" class="w-3.5 h-3.5"></i>
</button>
</div>
<div class="absolute top-2 left-2 z-10 opacity-100">
<div class="bg-black/50 backdrop-blur rounded p-0.5">
<input type="checkbox" checked class="w-4 h-4 rounded border-zinc-600 bg-zinc-800 text-indigo-600 focus:ring-indigo-500 focus:ring-offset-zinc-900">
</div>
</div>
<div class="aspect-video bg-zinc-950 relative">
<img src="https://images.unsplash.com/photo-1550745165-9bc0b252726f?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3" class="w-full h-full object-cover opacity-80" alt="Project">
<div class="absolute inset-0 ring-1 ring-inset ring-indigo-500/20"></div>
</div>
<div class="p-3">
<div class="flex items-start justify-between gap-2">
<h3 class="text-sm font-medium text-indigo-200 truncate">Tech Review 2025</h3>
<span class="text-[10px] text-zinc-500 font-mono whitespace-nowrap">12:00</span>
</div>
<div class="mt-2 flex items-center justify-between">
<div class="flex items-center gap-2 text-[11px] text-zinc-500">
<span class="w-2 h-2 rounded-full bg-blue-500/20 border border-blue-500/50"></span>
5h ago
</div>
<div class="flex -space-x-1.5">
<div class="w-5 h-5 rounded-full bg-zinc-800 border border-zinc-900 flex items-center justify-center text-[8px] text-zinc-400">JD</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Footer / Status Bar -->
<footer class="h-8 border-t border-zinc-800 bg-zinc-950 flex items-center px-4 justify-between text-[10px] text-zinc-500 select-none">
<div class="flex items-center gap-4">
<span>3 Projects</span>
<span>1 Selected</span>
</div>
<div class="flex items-center gap-4">
<span class="flex items-center gap-1"><div class="w-1.5 h-1.5 rounded-full bg-green-500"></div> Online</span>
<span>v2.4.0</span>
</div>
</footer>
</main>
<script>
lucide.createIcons();
</script>
</body>
</html>
+217
View File
@@ -0,0 +1,217 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Projects - Design 3</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&display=swap');
body { font-family: 'Space Grotesk', sans-serif; }
.border-b-1 { border-bottom-width: 1px; }
/* Brutalist hover effect */
.project-row:hover {
background-color: #000;
color: #fff;
}
.project-row:hover .meta-text { color: #9ca3af; }
.project-row:hover .icon-btn { color: #fff; border-color: #fff; }
/* Custom Checkbox */
.custom-checkbox {
appearance: none;
width: 1.25rem;
height: 1.25rem;
border: 2px solid #000;
cursor: pointer;
position: relative;
}
.custom-checkbox:checked {
background-color: #000;
}
.custom-checkbox:checked::after {
content: '✓';
color: #fff;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0.8rem;
}
.project-row:hover .custom-checkbox { border-color: #fff; }
.project-row:hover .custom-checkbox:checked { background-color: #fff; }
.project-row:hover .custom-checkbox:checked::after { color: #000; }
</style>
</head>
<body class="bg-white text-black min-h-screen flex flex-col">
<!-- Navigation -->
<nav class="border-b-2 border-black h-20 flex items-center justify-between px-8 sticky top-0 bg-white z-50">
<div class="flex items-center gap-12">
<a href="#" class="text-3xl font-bold tracking-tighter uppercase">Editor<span class="text-gray-400">.</span></a>
<div class="hidden md:flex gap-8 text-sm font-medium tracking-wide">
<a href="#" class="uppercase border-b-2 border-black pb-1">Projects</a>
<a href="#" class="uppercase text-gray-400 hover:text-black transition-colors">Assets</a>
<a href="#" class="uppercase text-gray-400 hover:text-black transition-colors">Team</a>
</div>
</div>
<div class="flex items-center gap-6">
<div class="relative group">
<input type="text" placeholder="SEARCH" class="border-b-2 border-gray-200 focus:border-black outline-none py-1 w-48 text-sm uppercase placeholder-gray-300 transition-all">
<i data-lucide="search" class="absolute right-0 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-300 group-focus-within:text-black"></i>
</div>
<button class="w-10 h-10 border-2 border-black rounded-full flex items-center justify-center hover:bg-black hover:text-white transition-colors">
<span class="font-bold text-sm">JD</span>
</button>
</div>
</nav>
<!-- Main Content -->
<main class="flex-1 flex flex-col max-w-7xl mx-auto w-full px-8 py-12">
<!-- Header Actions -->
<div class="flex items-end justify-between mb-16">
<div>
<h1 class="text-6xl font-bold tracking-tighter mb-4">WORK</h1>
<p class="text-xl text-gray-500 max-w-md leading-relaxed">Manage your creative output. Organize, edit, and publish your stories.</p>
</div>
<div class="flex gap-4">
<button class="px-8 py-4 border-2 border-black font-bold uppercase tracking-wider hover:bg-black hover:text-white transition-all flex items-center gap-3">
<i data-lucide="plus" class="w-5 h-5"></i>
New Project
</button>
</div>
</div>
<!-- Filters / Sort -->
<div class="flex items-center justify-between border-b-2 border-black pb-4 mb-0">
<div class="flex gap-8 text-sm font-bold uppercase tracking-wider">
<button class="text-black">All (12)</button>
<button class="text-gray-400 hover:text-black">Drafts (4)</button>
<button class="text-gray-400 hover:text-black">Published (8)</button>
</div>
<div class="flex items-center gap-4">
<button class="p-2 hover:bg-gray-100 rounded">
<i data-lucide="layout-list" class="w-5 h-5"></i>
</button>
<button class="p-2 text-gray-400 hover:text-black">
<i data-lucide="layout-grid" class="w-5 h-5"></i>
</button>
</div>
</div>
<!-- List View (Table-ish) -->
<div class="flex flex-col">
<!-- Header Row -->
<div class="grid grid-cols-12 gap-4 py-4 border-b border-gray-200 text-xs font-bold uppercase text-gray-400 tracking-wider">
<div class="col-span-1 flex justify-center">
<input type="checkbox" class="custom-checkbox">
</div>
<div class="col-span-5">Project Name</div>
<div class="col-span-2">Last Edited</div>
<div class="col-span-2">Duration</div>
<div class="col-span-2 text-right">Actions</div>
</div>
<!-- Row 1 -->
<div class="project-row group grid grid-cols-12 gap-4 py-6 border-b border-gray-200 items-center transition-colors cursor-pointer">
<div class="col-span-1 flex justify-center">
<input type="checkbox" class="custom-checkbox">
</div>
<div class="col-span-5 flex items-center gap-6">
<div class="w-24 h-16 bg-gray-100 overflow-hidden relative border border-gray-200 group-hover:border-white/20">
<img src="https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3" class="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all" alt="Thumb">
</div>
<div>
<h3 class="text-lg font-bold uppercase tracking-tight">Nike Commercial_V3</h3>
<span class="text-xs text-gray-400 meta-text uppercase tracking-wider">Client: Nike</span>
</div>
</div>
<div class="col-span-2 font-mono text-sm text-gray-500 meta-text">Today, 10:23 AM</div>
<div class="col-span-2 font-mono text-sm text-gray-500 meta-text">00:30:00</div>
<div class="col-span-2 flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button class="icon-btn w-8 h-8 border border-gray-300 rounded-full flex items-center justify-center hover:bg-white hover:text-black transition-colors">
<i data-lucide="pencil" class="w-3 h-3"></i>
</button>
<button class="icon-btn w-8 h-8 border border-gray-300 rounded-full flex items-center justify-center hover:bg-white hover:text-black transition-colors">
<i data-lucide="trash-2" class="w-3 h-3"></i>
</button>
</div>
</div>
<!-- Row 2 -->
<div class="project-row group grid grid-cols-12 gap-4 py-6 border-b border-gray-200 items-center transition-colors cursor-pointer bg-gray-50">
<div class="col-span-1 flex justify-center">
<input type="checkbox" class="custom-checkbox" checked>
</div>
<div class="col-span-5 flex items-center gap-6">
<div class="w-24 h-16 bg-gray-100 overflow-hidden relative border border-gray-200 group-hover:border-white/20">
<img src="https://images.unsplash.com/photo-1558655146-d09347e0b7a8?w=500&auto=format&fit=crop&q=60&ixlib=rb-4.0.3" class="w-full h-full object-cover grayscale group-hover:grayscale-0 transition-all" alt="Thumb">
</div>
<div>
<h3 class="text-lg font-bold uppercase tracking-tight">Fashion Week Recap</h3>
<span class="text-xs text-gray-400 meta-text uppercase tracking-wider">Personal</span>
</div>
</div>
<div class="col-span-2 font-mono text-sm text-gray-500 meta-text">Yesterday</div>
<div class="col-span-2 font-mono text-sm text-gray-500 meta-text">02:15:00</div>
<div class="col-span-2 flex justify-end gap-2 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
<button class="icon-btn w-8 h-8 border border-gray-300 rounded-full flex items-center justify-center hover:bg-white hover:text-black transition-colors">
<i data-lucide="pencil" class="w-3 h-3"></i>
</button>
<button class="icon-btn w-8 h-8 border border-gray-300 rounded-full flex items-center justify-center hover:bg-white hover:text-black transition-colors">
<i data-lucide="trash-2" class="w-3 h-3"></i>
</button>
</div>
</div>
<!-- Row 3 -->
<div class="project-row group grid grid-cols-12 gap-4 py-6 border-b border-gray-200 items-center transition-colors cursor-pointer">
<div class="col-span-1 flex justify-center">
<input type="checkbox" class="custom-checkbox">
</div>
<div class="col-span-5 flex items-center gap-6">
<div class="w-24 h-16 bg-gray-100 overflow-hidden relative border border-gray-200 group-hover:border-white/20">
<div class="w-full h-full flex items-center justify-center bg-gray-200 text-gray-400">
<i data-lucide="film" class="w-6 h-6"></i>
</div>
</div>
<div>
<h3 class="text-lg font-bold uppercase tracking-tight">Untitled Project 04</h3>
<span class="text-xs text-gray-400 meta-text uppercase tracking-wider">Draft</span>
</div>
</div>
<div class="col-span-2 font-mono text-sm text-gray-500 meta-text">Oct 24, 2025</div>
<div class="col-span-2 font-mono text-sm text-gray-500 meta-text">--:--:--</div>
<div class="col-span-2 flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button class="icon-btn w-8 h-8 border border-gray-300 rounded-full flex items-center justify-center hover:bg-white hover:text-black transition-colors">
<i data-lucide="pencil" class="w-3 h-3"></i>
</button>
<button class="icon-btn w-8 h-8 border border-gray-300 rounded-full flex items-center justify-center hover:bg-white hover:text-black transition-colors">
<i data-lucide="trash-2" class="w-3 h-3"></i>
</button>
</div>
</div>
</div>
<!-- Selection Action Bar (Sticky Bottom) -->
<div class="fixed bottom-8 left-1/2 -translate-x-1/2 bg-black text-white px-8 py-4 flex items-center gap-8 shadow-2xl z-50">
<span class="font-mono text-sm uppercase tracking-wider">1 Selected</span>
<div class="h-4 w-px bg-gray-700"></div>
<div class="flex gap-6">
<button class="hover:text-gray-300 uppercase text-sm font-bold tracking-wider">Delete</button>
<button class="hover:text-gray-300 uppercase text-sm font-bold tracking-wider">Duplicate</button>
<button class="hover:text-gray-300 uppercase text-sm font-bold tracking-wider">Archive</button>
</div>
</div>
</main>
<script>
lucide.createIcons();
</script>
</body>
</html>
+232
View File
@@ -0,0 +1,232 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dialog Designs</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap');
body { font-family: 'Inter', sans-serif; }
:root {
--brand-blue: #00A3FF;
}
/* Utility to hide/show */
.dialog-overlay { display: none; }
.dialog-overlay.open { display: flex; }
/* Stop body scroll when open */
body.dialog-open { overflow: hidden; }
</style>
</head>
<body class="bg-[#ffffff] text-slate-900 min-h-screen p-12">
<div class="max-w-4xl mx-auto space-y-4 mb-12 text-center">
<h1 class="text-2xl font-bold">Project Info Explorations</h1>
<p class="text-slate-500">Two layout approaches using the same "Structured" brand style.</p>
<div class="flex items-center justify-center gap-4 mt-8">
<button onclick="openDialog(1)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm transition-colors">
Layout 1: Cards & Grid
</button>
<button onclick="openDialog(2)" class="px-5 py-2.5 bg-white border border-slate-200 rounded-xl font-medium text-slate-700 hover:border-[#00A3FF] hover:text-[#00A3FF] shadow-sm transition-colors">
Layout 2: Clean Rows
</button>
</div>
</div>
<!-- Overlay (Shared) -->
<div id="overlay" class="dialog-overlay fixed inset-0 bg-slate-900/20 backdrop-blur-sm z-50 items-center justify-center p-4">
<!-- Option 1: Cards & Grid -->
<!-- Focus: High visibility, dashboard feel, grouping info into boxes -->
<div id="dialog-1" class="hidden w-full max-w-lg bg-white rounded-[20px] shadow-xl overflow-hidden flex flex-col">
<!-- Header -->
<div class="px-6 py-5 border-b border-slate-100 flex items-center justify-between bg-white">
<h2 class="text-lg font-bold text-slate-900">Project Info</h2>
<button onclick="closeAll()" class="text-slate-400 hover:text-slate-600 transition-colors">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<!-- Body -->
<div class="p-6 space-y-6">
<!-- Name Input -->
<div class="space-y-1.5">
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider">Project Name</label>
<div class="relative">
<input type="text" value="Summer Vlog 2025" class="w-full bg-white border border-slate-200 rounded-xl px-4 py-2.5 text-sm font-medium text-slate-900 focus:outline-none focus:border-[#00A3FF] focus:ring-4 focus:ring-blue-500/10 shadow-sm transition-shadow">
<div class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 pointer-events-none">
<i data-lucide="pencil" class="w-3.5 h-3.5"></i>
</div>
</div>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-3 gap-3">
<div class="p-3 bg-slate-50 border border-slate-100 rounded-xl text-center">
<p class="text-[10px] uppercase tracking-wide font-bold text-slate-400 mb-1">Duration</p>
<p class="text-sm font-semibold text-slate-900">03:45</p>
</div>
<div class="p-3 bg-slate-50 border border-slate-100 rounded-xl text-center">
<p class="text-[10px] uppercase tracking-wide font-bold text-slate-400 mb-1">Format</p>
<p class="text-sm font-semibold text-slate-900">1080p</p>
</div>
<div class="p-3 bg-slate-50 border border-slate-100 rounded-xl text-center">
<p class="text-[10px] uppercase tracking-wide font-bold text-slate-400 mb-1">FPS</p>
<p class="text-sm font-semibold text-slate-900">60</p>
</div>
</div>
<!-- Storage Info Card -->
<div class="p-4 bg-blue-50/50 rounded-xl border border-blue-100 flex gap-4">
<div class="w-10 h-10 rounded-full bg-blue-100 text-[#00A3FF] flex items-center justify-center flex-shrink-0">
<i data-lucide="hard-drive" class="w-5 h-5"></i>
</div>
<div class="flex-1">
<div class="flex justify-between items-center mb-1.5">
<span class="text-xs font-bold text-slate-700">Storage</span>
<span class="text-xs font-medium text-slate-500">1.2 GB</span>
</div>
<div class="h-2 w-full bg-blue-100 rounded-full overflow-hidden">
<div class="h-full bg-[#00A3FF] w-[35%] rounded-full"></div>
</div>
<p class="text-[11px] text-slate-400 mt-2">Last modified Jan 22, 2026</p>
</div>
</div>
</div>
<!-- Footer -->
<div class="px-6 py-4 bg-slate-50 border-t border-slate-100 flex items-center justify-end gap-3">
<button onclick="closeAll()" class="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors">
Close
</button>
<button class="px-4 py-2 text-sm font-bold text-white bg-[#00A3FF] hover:bg-[#008BE0] rounded-lg shadow-md shadow-blue-500/10 transition-colors">
Save Changes
</button>
</div>
</div>
<!-- Option 2: Clean Rows -->
<!-- Focus: Scannability, list view, technical details, minimal containers -->
<div id="dialog-2" class="hidden w-full max-w-lg bg-white rounded-[20px] shadow-xl overflow-hidden flex flex-col">
<!-- Header -->
<div class="px-6 py-5 border-b border-slate-100 flex items-center justify-between bg-white">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-slate-100 to-slate-200 border border-slate-200"></div>
<h2 class="text-lg font-bold text-slate-900">Project Details</h2>
</div>
<button onclick="closeAll()" class="text-slate-400 hover:text-slate-600 transition-colors">
<i data-lucide="x" class="w-5 h-5"></i>
</button>
</div>
<!-- Body -->
<div class="px-6 py-2">
<!-- Row 1: Name -->
<div class="py-4 flex items-center justify-between border-b border-slate-50">
<div class="flex items-center gap-3 text-slate-500">
<i data-lucide="type" class="w-4 h-4"></i>
<span class="text-sm font-medium">Name</span>
</div>
<input type="text" value="Summer Vlog 2025" class="text-right text-sm font-semibold text-slate-900 bg-transparent border-none focus:ring-0 w-48 p-0 placeholder-slate-300">
</div>
<!-- Row 2: Created -->
<div class="py-4 flex items-center justify-between border-b border-slate-50">
<div class="flex items-center gap-3 text-slate-500">
<i data-lucide="calendar" class="w-4 h-4"></i>
<span class="text-sm font-medium">Created</span>
</div>
<span class="text-sm font-medium text-slate-900">Jan 22, 2026</span>
</div>
<!-- Row 3: Technical -->
<div class="py-4 flex items-center justify-between border-b border-slate-50">
<div class="flex items-center gap-3 text-slate-500">
<i data-lucide="monitor" class="w-4 h-4"></i>
<span class="text-sm font-medium">Resolution</span>
</div>
<div class="flex items-center gap-2">
<span class="px-2 py-1 bg-slate-100 rounded-md text-xs font-bold text-slate-600">1080p</span>
<span class="px-2 py-1 bg-slate-100 rounded-md text-xs font-bold text-slate-600">60fps</span>
</div>
</div>
<!-- Row 4: Size -->
<div class="py-4 flex items-center justify-between">
<div class="flex items-center gap-3 text-slate-500">
<i data-lucide="hard-drive" class="w-4 h-4"></i>
<span class="text-sm font-medium">Size</span>
</div>
<span class="text-sm font-medium text-slate-900">1.2 GB</span>
</div>
</div>
<!-- Footer -->
<div class="px-6 py-4 bg-slate-50 border-t border-slate-100 flex items-center justify-between">
<button class="text-xs font-medium text-red-500 hover:text-red-600 transition-colors flex items-center gap-1.5">
<i data-lucide="trash-2" class="w-3.5 h-3.5"></i>
Delete Project
</button>
<div class="flex gap-3">
<button onclick="closeAll()" class="px-4 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 transition-colors">
Cancel
</button>
<button class="px-4 py-2 text-sm font-bold text-white bg-[#00A3FF] hover:bg-[#008BE0] rounded-lg shadow-md shadow-blue-500/10 transition-colors">
Done
</button>
</div>
</div>
</div>
</div>
<script>
lucide.createIcons();
const overlay = document.getElementById('overlay');
const dialogs = [
document.getElementById('dialog-1'),
document.getElementById('dialog-2')
];
function openDialog(id) {
// Hide all first
dialogs.forEach(d => d.classList.add('hidden'));
// Show overlay
overlay.classList.add('open');
document.body.classList.add('dialog-open');
// Show specific dialog
const target = document.getElementById(`dialog-${id}`);
if (target) {
target.classList.remove('hidden');
}
}
function closeAll() {
overlay.classList.remove('open');
document.body.classList.remove('dialog-open');
dialogs.forEach(d => d.classList.add('hidden'));
}
// Close on backdrop click
overlay.addEventListener('click', (e) => {
if (e.target === overlay) {
closeAll();
}
});
// Escape key support
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAll();
});
</script>
</body>
</html>
+98
View File
@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dropdown Options</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap');
body { font-family: 'Inter', sans-serif; }
:root {
--brand-blue: #00A3FF;
}
/* Utility to hide/show */
.dropdown-menu { display: none; }
.dropdown-menu.open { display: block; }
/* Custom scrollbar for dropdowns if needed */
.custom-scroll::-webkit-scrollbar { width: 4px; }
.custom-scroll::-webkit-scrollbar-track { background: transparent; }
.custom-scroll::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
</style>
</head>
<body class="bg-[#ffffff] text-slate-900 min-h-screen p-12">
<div class="max-w-6xl mx-auto space-y-4 mb-12">
<h1 class="text-2xl font-bold">Dropdown Design Explorations</h1>
<p class="text-slate-500">Single interaction for the same brand language. Click the button to toggle.</p>
</div>
<div class="grid grid-cols-1 gap-16 items-start max-w-6xl mx-auto">
<!-- Option 1: The "Soft & Minimal" -->
<!-- Characteristics: Extra rounded corners, blue text on hover, very subtle dividers -->
<div class="space-y-4">
<h2 class="text-sm font-bold text-slate-400 uppercase tracking-wider">Option 1: Soft & Minimal</h2>
<div class="relative w-72">
<button onclick="toggle(1)" class="w-72 bg-white border border-slate-200 px-3 py-2 rounded-2xl flex items-center justify-between font-medium text-slate-700 hover:border-slate-300 hover:text-slate-900 shadow-sm">
<span>Sort by</span>
<i data-lucide="chevron-down" class="w-4 h-4 text-slate-400"></i>
</button>
<div id="menu-1" class="dropdown-menu absolute top-full left-0 mt-2 w-72 bg-white rounded-[20px] shadow-lg border border-slate-200 overflow-hidden py-2 z-10">
<div class="flex flex-col gap-0.5">
<div class="px-2 pb-2 mb-2 border-b border-slate-50">
<p class="text-[11px] font-bold text-slate-400 uppercase px-3 pt-2 pb-1">Order</p>
</div>
<a href="#" class="block px-4 py-2.5 mx-2 text-sm text-[#00A3FF] bg-blue-50 rounded-xl hover:bg-blue-50 hover:text-[#00A3FF] flex items-center justify-between group">
<span>Date created</span>
<i data-lucide="check" class="w-4 h-4 text-[#00A3FF]"></i>
</a>
<a href="#" class="block px-4 py-2.5 mx-2 text-sm text-slate-600 rounded-xl hover:bg-slate-50 flex items-center justify-between group">
<span>Last modified</span>
</a>
<a href="#" class="block px-4 py-2.5 mx-2 text-sm text-slate-600 rounded-xl hover:bg-slate-50 flex items-center justify-between group">
<span>Alphabetical</span>
</a>
<div class="h-px bg-slate-50 my-2 mx-2"></div>
<a href="#" class="block px-4 py-2.5 mx-2 text-sm text-red-500 rounded-xl hover:bg-red-50 flex items-center gap-2">
<i data-lucide="trash" class="w-4 h-4"></i>
<span>Reset sort</span>
</a>
</div>
</div>
</div>
</div>
</div>
<script>
lucide.createIcons();
function toggle(id) {
const menu = document.getElementById(`menu-${id}`);
if (menu) {
menu.classList.toggle('open');
}
}
// Close when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('button') && !e.target.closest('.dropdown-menu')) {
document.querySelectorAll('.dropdown-menu').forEach(el => el.classList.remove('open'));
}
});
// Open all by default for the demo view so user doesn't have to click
// toggle(1);
</script>
</body>
</html>
+3598
View File
File diff suppressed because it is too large Load Diff
+771
View File
@@ -0,0 +1,771 @@
import {
ALL_FORMATS,
AudioBufferSink,
BlobSource,
CanvasSink,
Input,
UrlSource,
type WrappedAudioBuffer,
type WrappedCanvas,
} from "mediabunny";
import SampleFileUrl from "../../docs/assets/big-buck-bunny-trimmed.mp4";
(document.querySelector("#sample-file-download") as HTMLAnchorElement).href =
SampleFileUrl;
const selectMediaButton = document.querySelector(
"#select-file",
) as HTMLButtonElement;
const loadUrlButton = document.querySelector("#load-url") as HTMLButtonElement;
const fileNameElement = document.querySelector(
"#file-name",
) as HTMLParagraphElement;
const horizontalRule = document.querySelector("hr") as HTMLHRElement;
const loadingElement = document.querySelector(
"#loading-element",
) as HTMLParagraphElement;
const playerContainer = document.querySelector("#player") as HTMLDivElement;
const canvas = document.querySelector("canvas") as HTMLCanvasElement;
const controlsElement = document.querySelector("#controls") as HTMLDivElement;
const playButton = document.querySelector("#play-button") as HTMLButtonElement;
const playIcon = document.querySelector("#play-icon") as HTMLSpanElement;
const pauseIcon = document.querySelector("#pause-icon") as HTMLSpanElement;
const currentTimeElement = document.querySelector(
"#current-time",
) as HTMLSpanElement;
const durationElement = document.querySelector("#duration") as HTMLSpanElement;
const progressBarContainer = document.querySelector(
"#progress-bar-container",
) as HTMLDivElement;
const progressBar = document.querySelector("#progress-bar") as HTMLDivElement;
const volumeBarContainer = document.querySelector(
"#volume-bar-container",
) as HTMLDivElement;
const volumeBar = document.querySelector("#volume-bar") as HTMLDivElement;
const volumeIconWrapper = document.querySelector(
"#volume-icon-wrapper",
) as HTMLDivElement;
const volumeButton = document.querySelector(
"#volume-button",
) as HTMLButtonElement;
const fullscreenButton = document.querySelector(
"#fullscreen-button",
) as HTMLButtonElement;
const errorElement = document.querySelector("#error-element") as HTMLDivElement;
const warningElement = document.querySelector(
"#warning-element",
) as HTMLDivElement;
const context = canvas.getContext("2d")!;
let audioContext: AudioContext | null = null;
let gainNode: GainNode | null = null;
let fileLoaded = false;
let videoSink: CanvasSink | null = null;
let audioSink: AudioBufferSink | null = null;
let totalDuration = 0;
/** The value of the audio context's currentTime the moment the playback was started. */
let audioContextStartTime: number | null = null;
let playing = false;
/** The timestamp within the media file when the playback was started. */
let playbackTimeAtStart = 0;
let videoFrameIterator: AsyncGenerator<WrappedCanvas, void, unknown> | null =
null;
let audioBufferIterator: AsyncGenerator<
WrappedAudioBuffer,
void,
unknown
> | null = null;
let nextFrame: WrappedCanvas | null = null;
const queuedAudioNodes: Set<AudioBufferSourceNode> = new Set();
/**
* Used to prevent async race conditions. When seekId is incremented, already-running async functions will be prevented
* from having an effect.
*/
let asyncId = 0;
let draggingProgressBar = false;
let volume = 0.7;
let draggingVolumeBar = false;
let volumeMuted = false;
/** === INIT LOGIC === */
const initMediaPlayer = async (resource: File | string) => {
try {
// First, dispose any ongoing playback:
if (playing) {
pause();
}
void videoFrameIterator?.return();
void audioBufferIterator?.return();
asyncId++;
fileLoaded = false;
fileNameElement.textContent =
resource instanceof File ? resource.name : resource;
horizontalRule.style.display = "";
loadingElement.style.display = "";
playerContainer.style.display = "none";
errorElement.textContent = "";
warningElement.textContent = "";
// Create an Input from the resource
const source =
resource instanceof File
? new BlobSource(resource)
: new UrlSource(resource);
const input = new Input({
source,
formats: ALL_FORMATS,
});
playbackTimeAtStart = 0;
totalDuration = await input.computeDuration();
durationElement.textContent = formatSeconds(totalDuration);
let videoTrack = await input.getPrimaryVideoTrack();
let audioTrack = await input.getPrimaryAudioTrack();
let problemMessage = "";
if (videoTrack) {
if (videoTrack.codec === null) {
problemMessage += "Unsupported video codec. ";
videoTrack = null;
} else if (!(await videoTrack.canDecode())) {
problemMessage += "Unable to decode the video track. ";
videoTrack = null;
}
}
if (audioTrack) {
if (audioTrack.codec === null) {
problemMessage += "Unsupported audio codec. ";
audioTrack = null;
} else if (!(await audioTrack.canDecode())) {
problemMessage += "Unable to decode the audio track. ";
audioTrack = null;
}
}
if (!videoTrack && !audioTrack) {
if (!problemMessage) {
problemMessage = "No audio or video track found.";
}
throw new Error(problemMessage);
}
if (problemMessage) {
warningElement.textContent = problemMessage;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access
const AudioContext =
window.AudioContext || (window as any).webkitAudioContext;
// We must create the audio context with the matching sample rate for correct acoustic results
// (especially for low-sample rate files)
audioContext = new AudioContext({ sampleRate: audioTrack?.sampleRate });
gainNode = audioContext.createGain();
gainNode.connect(audioContext.destination);
updateVolume();
const videoCanBeTransparent = videoTrack
? await videoTrack.canBeTransparent()
: false;
playerContainer.style.background = videoCanBeTransparent
? "transparent"
: "";
// For video, let's use a CanvasSink as it handles rotation and closing video samples for us.
// Pool size of 2: We'll only ever have the current and the next frame around, so we only need two canvases.
videoSink =
videoTrack &&
new CanvasSink(videoTrack, {
poolSize: 2,
fit: "contain", // In case the video changes dimensions over time
alpha: videoCanBeTransparent,
});
// For audio, we'll use an AudioBufferSink to directly retrieve AudioBuffers compatible with the Web Audio API
audioSink = audioTrack && new AudioBufferSink(audioTrack);
// Show the canvas if there's a video track, otherwise hide it
if (videoTrack) {
canvas.style.display = "";
canvas.width = videoTrack.displayWidth;
canvas.height = videoTrack.displayHeight;
} else {
canvas.style.display = "none";
}
// Show volume controls if there's an audio track, otherwise hide them
if (audioTrack) {
volumeButton.style.display = "";
volumeBarContainer.style.display = "";
} else {
volumeButton.style.display = "none";
volumeBarContainer.style.display = "none";
}
fileLoaded = true;
await startVideoIterator();
if (audioContext.state === "running") {
// Start playback automatically if the audio context permits
await play();
}
loadingElement.style.display = "none";
playerContainer.style.display = "";
if (!videoSink) {
// If there's only an audio track, always show the controls
controlsElement.style.opacity = "1";
controlsElement.style.pointerEvents = "";
playerContainer.style.cursor = "";
}
} catch (error) {
console.error(error);
errorElement.textContent = String(error);
loadingElement.style.display = "none";
playerContainer.style.display = "none";
}
};
/** === VIDEO RENDERING LOGIC === */
/** Creates a new video frame iterator and renders the first video frame. */
const startVideoIterator = async () => {
if (!videoSink) {
return;
}
asyncId++;
await videoFrameIterator?.return(); // Dispose of the current iterator
// Create a new iterator
videoFrameIterator = videoSink.canvases(getPlaybackTime());
// Get the first two frames
const firstFrame = (await videoFrameIterator.next()).value ?? null;
const secondFrame = (await videoFrameIterator.next()).value ?? null;
nextFrame = secondFrame;
if (firstFrame) {
// Draw the first frame
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(firstFrame.canvas, 0, 0);
}
};
/** Runs every frame; updates the canvas if necessary. */
const render = (requestFrame = true) => {
if (fileLoaded) {
const playbackTime = getPlaybackTime();
if (playbackTime >= totalDuration) {
// Pause playback once the end is reached
pause();
playbackTimeAtStart = totalDuration;
}
// Check if the current playback time has caught up to the next frame
if (nextFrame && nextFrame.timestamp <= playbackTime) {
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(nextFrame.canvas, 0, 0);
nextFrame = null;
// Request the next frame
void updateNextFrame();
}
if (!draggingProgressBar) {
updateProgressBarTime(playbackTime);
}
}
if (requestFrame) {
requestAnimationFrame(() => render());
}
};
render();
// Also call the render function on an interval to make sure the video keeps updating even if the tab isn't visible
setInterval(() => render(false), 500);
/** Iterates over the video frame iterator until it finds a video frame in the future. */
const updateNextFrame = async () => {
const currentAsyncId = asyncId;
// We have a loop here because we may need to iterate over multiple frames until we reach a frame in the future
while (true) {
const newNextFrame = (await videoFrameIterator!.next()).value ?? null;
if (!newNextFrame) {
break;
}
if (currentAsyncId !== asyncId) {
break;
}
const playbackTime = getPlaybackTime();
if (newNextFrame.timestamp <= playbackTime) {
// Draw it immediately
context.clearRect(0, 0, canvas.width, canvas.height);
context.drawImage(newNextFrame.canvas, 0, 0);
} else {
// Save it for later
nextFrame = newNextFrame;
break;
}
}
};
/** === AUDIO PLAYBACK LOGIC === */
/** Loops over the audio buffer iterator, scheduling the audio to be played in the audio context. */
const runAudioIterator = async () => {
if (!audioSink) {
return;
}
// To play back audio, we loop over all audio chunks (typically very short) of the file and play them at the correct
// timestamp. The result is a continuous, uninterrupted audio signal.
for await (const { buffer, timestamp } of audioBufferIterator!) {
const node = audioContext!.createBufferSource();
node.buffer = buffer;
node.connect(gainNode!);
const startTimestamp =
audioContextStartTime! + timestamp - playbackTimeAtStart;
// Two cases: Either, the audio starts in the future or in the past
if (startTimestamp >= audioContext!.currentTime) {
// If the audio starts in the future, easy, we just schedule it
node.start(startTimestamp);
} else {
// If it starts in the past, then let's only play the audible section that remains from here on out
node.start(
audioContext!.currentTime,
audioContext!.currentTime - startTimestamp,
);
}
queuedAudioNodes.add(node);
node.onended = () => {
queuedAudioNodes.delete(node);
};
// If we're more than a second ahead of the current playback time, let's slow down the loop until time has
// passed.
if (timestamp - getPlaybackTime() >= 1) {
await new Promise<void>((resolve) => {
const id = setInterval(() => {
if (timestamp - getPlaybackTime() < 1) {
clearInterval(id);
resolve();
}
}, 100);
});
}
}
};
/** === PLAYBACK CONTROL LOGIC === */
/** Returns the current playback time in the media file. */
const getPlaybackTime = () => {
if (playing) {
// To ensure perfect audio-video sync, we always use the audio context's clock to determine playback time, even
// when there is no audio track.
return (
audioContext!.currentTime - audioContextStartTime! + playbackTimeAtStart
);
} else {
return playbackTimeAtStart;
}
};
const play = async () => {
if (audioContext!.state === "suspended") {
await audioContext!.resume();
}
if (getPlaybackTime() === totalDuration) {
// If we're at the end, let's snap back to the start
playbackTimeAtStart = 0;
await startVideoIterator();
}
audioContextStartTime = audioContext!.currentTime;
playing = true;
if (audioSink) {
// Start the audio iterator
void audioBufferIterator?.return();
audioBufferIterator = audioSink?.buffers(getPlaybackTime());
void runAudioIterator();
}
playIcon.style.display = "none";
pauseIcon.style.display = "";
};
const pause = () => {
playbackTimeAtStart = getPlaybackTime();
playing = false;
void audioBufferIterator?.return(); // This stops any for-loops that are iterating the iterator
audioBufferIterator = null;
// Stop all audio nodes that were already queued to play
for (const node of queuedAudioNodes) {
node.stop();
}
queuedAudioNodes.clear();
playIcon.style.display = "";
pauseIcon.style.display = "none";
};
const togglePlay = () => {
if (playing) {
pause();
} else {
void play();
}
};
const seekToTime = async (seconds: number) => {
updateProgressBarTime(seconds);
const wasPlaying = playing;
if (wasPlaying) {
pause();
}
playbackTimeAtStart = seconds;
await startVideoIterator();
if (wasPlaying && playbackTimeAtStart < totalDuration) {
void play();
}
};
/** === PROGRESS BAR LOGIC === */
const updateProgressBarTime = (seconds: number) => {
currentTimeElement.textContent = formatSeconds(seconds);
progressBar.style.width = `${(seconds / totalDuration) * 100}%`;
};
progressBarContainer.addEventListener("pointerdown", (event) => {
draggingProgressBar = true;
progressBarContainer.setPointerCapture(event.pointerId);
const rect = progressBarContainer.getBoundingClientRect();
const completion = Math.max(
Math.min((event.clientX - rect.left) / rect.width, 1),
0,
);
updateProgressBarTime(completion * totalDuration);
clearTimeout(hideControlsTimeout);
window.addEventListener(
"pointerup",
(event) => {
draggingProgressBar = false;
progressBarContainer.releasePointerCapture(event.pointerId);
const rect = progressBarContainer.getBoundingClientRect();
const completion = Math.max(
Math.min((event.clientX - rect.left) / rect.width, 1),
0,
);
const newTime = completion * totalDuration;
void seekToTime(newTime);
showControlsTemporarily();
},
{ once: true },
);
});
progressBarContainer.addEventListener("pointermove", (event) => {
if (draggingProgressBar) {
const rect = progressBarContainer.getBoundingClientRect();
const completion = Math.max(
Math.min((event.clientX - rect.left) / rect.width, 1),
0,
);
updateProgressBarTime(completion * totalDuration);
}
});
/** === VOLUME CONTROL LOGIC === */
const updateVolume = () => {
const actualVolume = volumeMuted ? 0 : volume;
volumeBar.style.width = `${actualVolume * 100}%`;
gainNode!.gain.value = actualVolume ** 2; // Quadratic for more fine-grained control
const iconNumber = volumeMuted ? 0 : Math.ceil(1 + 3 * volume);
for (let i = 0; i < volumeIconWrapper.children.length; i++) {
const icon = volumeIconWrapper.children[i] as HTMLImageElement;
icon.style.display = i === iconNumber ? "" : "none";
}
};
volumeBarContainer.addEventListener("pointerdown", (event) => {
draggingVolumeBar = true;
volumeBarContainer.setPointerCapture(event.pointerId);
const rect = volumeBarContainer.getBoundingClientRect();
volume = Math.max(Math.min((event.clientX - rect.left) / rect.width, 1), 0);
volumeMuted = false;
updateVolume();
clearTimeout(hideControlsTimeout);
window.addEventListener(
"pointerup",
(event) => {
draggingVolumeBar = false;
volumeBarContainer.releasePointerCapture(event.pointerId);
const rect = volumeBarContainer.getBoundingClientRect();
volume = Math.max(
Math.min((event.clientX - rect.left) / rect.width, 1),
0,
);
updateVolume();
showControlsTemporarily();
},
{ once: true },
);
});
volumeButton.addEventListener("click", () => {
volumeMuted = !volumeMuted;
updateVolume();
});
volumeBarContainer.addEventListener("pointermove", (event) => {
if (draggingVolumeBar) {
const rect = volumeBarContainer.getBoundingClientRect();
volume = Math.max(Math.min((event.clientX - rect.left) / rect.width, 1), 0);
updateVolume();
}
});
/** === CONTROL UI LOGIC === */
const showControlsTemporarily = () => {
if (!videoSink) {
// Shouldn't run if there's only an audio track
return;
}
controlsElement.style.opacity = "1";
controlsElement.style.pointerEvents = "";
playerContainer.style.cursor = "";
clearTimeout(hideControlsTimeout);
hideControlsTimeout = window.setTimeout(() => {
if (draggingProgressBar) {
return;
}
hideControls();
playerContainer.style.cursor = "none";
}, 2000);
};
const hideControls = () => {
controlsElement.style.opacity = "0";
controlsElement.style.pointerEvents = "none";
};
hideControls();
let hideControlsTimeout = -1;
playerContainer.addEventListener("pointermove", (event) => {
if (event.pointerType !== "touch") {
showControlsTemporarily();
}
});
playerContainer.addEventListener("pointerleave", (event) => {
if (!videoSink) {
// Shouldn't run if there's only an audio track
return;
}
if (
draggingProgressBar ||
draggingVolumeBar ||
event.pointerType === "touch"
) {
return;
}
hideControls();
clearTimeout(hideControlsTimeout);
});
/** === EVENT LISTENERS === */
playButton.addEventListener("click", togglePlay);
window.addEventListener("keydown", (e) => {
if (!fileLoaded) {
return;
}
if (e.code === "Space" || e.code === "KeyK") {
togglePlay();
} else if (e.code === "KeyF") {
fullscreenButton.click();
} else if (e.code === "ArrowLeft") {
const newTime = Math.max(getPlaybackTime() - 5, 0);
void seekToTime(newTime);
} else if (e.code === "ArrowRight") {
const newTime = Math.min(getPlaybackTime() + 5, totalDuration);
void seekToTime(newTime);
} else if (e.code === "KeyM") {
volumeButton.click();
} else {
return;
}
showControlsTemporarily();
e.preventDefault();
});
fullscreenButton.addEventListener("click", () => {
if (document.fullscreenElement) {
void document.exitFullscreen();
} else {
playerContainer.requestFullscreen().catch((e) => {
console.error("Failed to enter fullscreen mode:", e);
});
}
});
// I'm sorry for this
const isTouchDevice = () => {
return "ontouchstart" in window;
};
playerContainer.addEventListener("click", () => {
if (isTouchDevice()) {
if (controlsElement.style.opacity === "1") {
hideControls();
} else {
showControlsTemporarily();
}
} else {
togglePlay();
}
});
controlsElement.addEventListener("click", (event) => {
// Make sure this does NOT toggle play
event.stopPropagation();
showControlsTemporarily();
});
/** === UTILS === */
const formatSeconds = (seconds: number) => {
const showMilliseconds = window.innerWidth >= 640;
seconds = Math.round(seconds * 1000) / 1000; // Round to milliseconds
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60);
const millisecs = Math.floor((1000 * seconds) % 1000)
.toString()
.padStart(3, "0");
let result: string;
if (hours > 0) {
result =
`${hours}:${minutes.toString().padStart(2, "0")}` +
`:${remainingSeconds.toString().padStart(2, "0")}`;
} else {
result = `${minutes.toString().padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`;
}
if (showMilliseconds) {
result += `.${millisecs}`;
}
return result;
};
window.addEventListener("resize", () => {
if (totalDuration) {
updateProgressBarTime(getPlaybackTime());
durationElement.textContent = formatSeconds(totalDuration);
}
});
/** === FILE SELECTION LOGIC === */
selectMediaButton.addEventListener("click", () => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept =
"video/*,video/x-matroska,video/mp2t,.ts,audio/*,audio/aac";
fileInput.addEventListener("change", () => {
const file = fileInput.files?.[0];
if (!file) {
return;
}
void initMediaPlayer(file);
});
fileInput.click();
});
loadUrlButton.addEventListener("click", () => {
const url = prompt(
"Please enter a URL of a media file. Note that it must be HTTPS and support cross-origin requests, so have the" +
" right CORS headers set.",
"https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
);
if (!url) {
return;
}
void initMediaPlayer(url);
});
document.addEventListener("dragover", (event) => {
event.preventDefault();
event.dataTransfer!.dropEffect = "copy";
});
document.addEventListener("drop", (event) => {
event.preventDefault();
const files = event.dataTransfer?.files;
const file = files && files.length > 0 ? files[0] : undefined;
if (file) {
void initMediaPlayer(file);
}
});
+70
View File
@@ -0,0 +1,70 @@
Review every point below carefully for files to ensure they follow consistent code style and best practices.
1. Every single function must accept a single object parameter and destructure it in the signature (for readability and future extensibility), if there's 1 or more values. This applies to all functions, not just react components. Even zustand stores. exception: tiny one-liner callbacks (e.g. `array.find(x => ...)`, `map`, `filter`, `sort`) do not need destructuring if it hurts readability or adds noise.
2. No `any` references.
3. JSX is clean; No comments in there explaining what each part does. Must extract JSX into sub-components (always placed below the main component). A part only needs to be extracted if it's either used in multiple places of the file OR is complex enough to be worth extracting. If a component is used acrosss multiple files, it should be extracted to a separate file.
4. General interfaces must be placed in the `types` folder. Example: `src/components/timeline/index.tsx` defining a `TimelineTrack` interface is wrong and should be moved to `src/types/timeline.ts`.
5. No AI comments — only human comments. Example of AI comments: explaining too much, code is readable without the comment, changelog-style comments, using more words than necessary, etc.
6. All comments must be all lowercase.
7. JSX: Use `gap-2` instead of `mb-2` or `mt-2` for consistent spacing.
8. Code is scannable. Use variables and helper functions to make intent clear at a glance. Complex code should be clean.
9. Separation of concerns: Each file should have one single purpose/responsibility. Example: `src/components/timeline/index.tsx` should not define a function to validate element track compatibility. That's a separate concern and belongs in a separate file. `src/lib/timeline-utils.ts` should not declare a TRACK_COLORS constant. That belongs in src/constants. Think carefully about what belongs in what file.
10. Files with react components must use this order for functions: main function (top) -> sub-components (below).
11. Use zustand correctly. React component should never use `someStore.getState()`. Instead, use the `useSomeStore` hook.
12. Business logic is in `src/lib` folder. Example: zustand store has a method to remove a bookmark, the method should be a wrapper of a function in `src/lib/` that handles the actual logic.
13. Booleans must be named like `isSomething` or `hasSomething` or `shouldSomething`. Not `something` or `somethingIs`.
14. No text in docs or UI ever uses title case. Example: `Hello World` is wrong. It should be `Hello world`.
15. Use `size-10` instead of `h-10 w-10` when the width and height are the same.
16. When using `size-` on an SVG (lucide, react-icons, @opencut/ui/icons, etc.) inside of a <Button> (not <buttom>), make sure to use the `!` modifier to ensure the size is applied correctly. This is becuase the <Button> component has a default size of `size-4` that can only be overridden with the `!` modifier. That also means, don't do `!size-4` since that's the default size.
- Example:
```tsx
<Button>
<PlusIcon className="!size-6" /> // This is correct ✅
<PlusIcon className="size-6" /> // This is wrong ❌
<PlusIcon className="!size-4" /> // This is wrong ❌
<span>Add</span>
</Button>
```
17. For components that need to subscribe to data from the editor api (`src/core`, `src/managers`), use the `useEditor` hook.
18. In react components: store/manager methods should not be passed as props to sub-components. If a sub-component can access the same methods, it should do so. Example:
```tsx
import { useTimelineStore } from "@/stores/timeline-store";
// ❌ Do NOT do this:
function ParentComponent() {
const { selectedElements } = useTimelineStore();
return <ChildComponent selectedElements={selectedElements} />;
}
function ChildComponent({ selectedElements }) {}
// ✅ Do this:
function ParentComponent() {
return <ChildComponent />;
}
function ChildComponent() {
const { selectedElements } = useTimelineStore();
}
```
```
19. 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: "element" is better than "el".
# Functions
- Next.js page components should use the keyword `export default function`
- Main react component should use the keyword `export function`
- Sub-components should use the keyword `function`
- Utility functions should use the keyword `function`
- Functions inside of react components should use the keyword `const`
In other words: use common sense and good judgement. Don't be lazy. Consider every decision to be like gold: extremely important and valuable. Every decision, every edit must be carefully considered. Everything matters. We want to scale this project massively. And every line of code contributes to that.
```
+63
View File
@@ -0,0 +1,63 @@
# Twiggy Configuration
#
# Twiggy generates real-time directory structure and codebase index for Cursor AI
# Your AI always knows your codebase's structure and API surface automatically
# https://github.com/twiggy-tools/Twiggy
#
# This file controls what folders and files Twiggy monitors and indexes
# Note: Generated .cursor/rules/ files are not tracked in git by design
# Sync with .gitignore - automatically exclude anything in your .gitignore
syncWithGitignore: true
# File Structure Configuration
structure:
# Output format: 'xml' (better for LLMs) or 'tree' (visual with ├── └──)
format: tree
# Custom folders/files to exclude from structure generation
# Examples: 'temp', 'src/old-stuff', 'docs/legacy/backup'
exclude:
- "python"
- "legacy"
- "apps/tools"
- ".vscode"
- ".turbo"
- "apps/web/migrations"
- "apps/web/src/components/ui"
- "apps/web/src/middleware.ts"
# Codebase Indexing Configuration
# Extracts function signatures, types, interfaces, classes, and exports
# This helps AI understand what utilities and types exist in your codebase
indexing:
# Enable/disable codebase indexing
enabled: true
# Include patterns (empty = index everything)
# Examples: ["src/**/*.ts", "lib/**/*.ts"]
include:
[
"apps/web/**/*.ts",
"apps/web/**/*.tsx",
"packages/**/*.ts",
"packages/**/*.tsx",
]
# Exclude patterns (in addition to defaults)
# By default: test files, config files, build outputs, and node_modules are excluded
# Add your own patterns here:
exclude:
- "python/**"
- "legacy/**"
- "apps/tools/**"
- ".vscode/**"
- ".turbo/**"
- "apps/web/src/app/**"
- "apps/web/src/components/**"
- "apps/web/stores/**"
- "apps/web/hooks/**"
- "apps/web/src/core/managers/**"
- "apps/web/src/lib/commands/**"
- "apps/web/src/data/**"
- "apps/web/src/services/renderer/nodes/**"