Merge remote-tracking branch 'upstream/main' into uat
This commit is contained in:
commit
26ac7c1003
|
|
@ -1,21 +1,21 @@
|
|||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,344 +1,344 @@
|
|||
{
|
||||
"id": "33a6742f-89da-4ac5-958f-421aa1cf9bd6",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.accounts": {
|
||||
"name": "accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"accounts_user_id_users_id_fk": {
|
||||
"name": "accounts_user_id_users_id_fk",
|
||||
"tableFrom": "accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"sessions_token_unique": {
|
||||
"name": "sessions_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["token"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.verifications": {
|
||||
"name": "verifications",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.waitlist": {
|
||||
"name": "waitlist",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"waitlist_email_unique": {
|
||||
"name": "waitlist_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
"id": "33a6742f-89da-4ac5-958f-421aa1cf9bd6",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.accounts": {
|
||||
"name": "accounts",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"accounts_user_id_users_id_fk": {
|
||||
"name": "accounts_user_id_users_id_fk",
|
||||
"tableFrom": "accounts",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.sessions": {
|
||||
"name": "sessions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"sessions_user_id_users_id_fk": {
|
||||
"name": "sessions_user_id_users_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"sessions_token_unique": {
|
||||
"name": "sessions_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["token"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.verifications": {
|
||||
"name": "verifications",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
},
|
||||
"public.waitlist": {
|
||||
"name": "waitlist",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"waitlist_email_unique": {
|
||||
"name": "waitlist_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": true
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1750753385927,
|
||||
"tag": "0000_brainy_saracen",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1750753385927,
|
||||
"tag": "0000_brainy_saracen",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,48 +2,48 @@ import type { NextConfig } from "next";
|
|||
import { withBotId } from "botid/next/config";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === "production",
|
||||
},
|
||||
reactStrictMode: true,
|
||||
productionBrowserSourceMaps: true,
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "plus.unsplash.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.marblecms.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "lh3.googleusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "avatars.githubusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "api.iconify.design",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "api.simplesvg.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "api.unisvg.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === "production",
|
||||
},
|
||||
reactStrictMode: true,
|
||||
productionBrowserSourceMaps: true,
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "plus.unsplash.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.unsplash.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "images.marblecms.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "lh3.googleusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "avatars.githubusercontent.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "api.iconify.design",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "api.simplesvg.com",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "api.unisvg.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default withBotId(nextConfig);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<circle cx="50" cy="50" r="44" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 126 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<polygon points="50,6 94,50 50,94 6,50" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 135 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<polygon points="28,8 72,8 94,50 72,92 28,92 6,50" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 146 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<rect x="10" y="10" width="80" height="80" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 138 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<polygon points="50,6 61,37 94,37 67,56 77,88 50,68 23,88 33,56 6,37 39,37" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 171 B |
|
|
@ -0,0 +1,3 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<polygon points="50,8 94,92 6,92" fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 129 B |
|
|
@ -0,0 +1,281 @@
|
|||
/**
|
||||
* Generates font sprite atlas for the font picker.
|
||||
*
|
||||
* Downloads Google Fonts from Fontsource, renders each font name as a sprite,
|
||||
* packs them into chunk images, and outputs a JSON atlas + AVIF images.
|
||||
*
|
||||
* Run: npx tsx scripts/generate-font-sprites.ts
|
||||
* Deps: @napi-rs/canvas sharp (install as devDependencies)
|
||||
*/
|
||||
|
||||
import { createCanvas, GlobalFonts } from "@napi-rs/canvas";
|
||||
import sharp from "sharp";
|
||||
import { mkdir, writeFile, readFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const FONT_SIZE = 24;
|
||||
const ROW_HEIGHT = 40;
|
||||
const CANVAS_WIDTH = 1200;
|
||||
const MAX_CHUNK_HEIGHT = 800;
|
||||
const PADDING_X = 14;
|
||||
const WIDTH_BUFFER = 8;
|
||||
const CONCURRENT_DOWNLOADS = 30;
|
||||
|
||||
const OUTPUT_DIR = join(__dirname, "..", "public", "fonts");
|
||||
const CACHE_DIR = join(__dirname, "..", ".font-cache");
|
||||
const FONTSOURCE_API = "https://api.fontsource.org/v1/fonts";
|
||||
|
||||
interface FontsourceFont {
|
||||
id: string;
|
||||
family: string;
|
||||
subsets: string[];
|
||||
weights: number[];
|
||||
styles: string[];
|
||||
category: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface MeasuredFont {
|
||||
family: string;
|
||||
width: number;
|
||||
styles: string[];
|
||||
}
|
||||
|
||||
interface PackedFont {
|
||||
family: string;
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
}
|
||||
|
||||
interface AtlasEntry {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
ch: number;
|
||||
s: string[];
|
||||
}
|
||||
|
||||
async function fetchFontList(): Promise<FontsourceFont[]> {
|
||||
console.log("Fetching font list from Fontsource...");
|
||||
const response = await fetch(FONTSOURCE_API);
|
||||
if (!response.ok) throw new Error(`Fontsource API error: ${response.status}`);
|
||||
|
||||
const data: FontsourceFont[] = await response.json();
|
||||
const filtered = data.filter(
|
||||
(font) =>
|
||||
font.type === "google" &&
|
||||
font.subsets.includes("latin") &&
|
||||
font.weights.includes(400),
|
||||
);
|
||||
|
||||
console.log(
|
||||
` ${filtered.length} Google fonts with Latin subset + 400 weight`,
|
||||
);
|
||||
return filtered;
|
||||
}
|
||||
|
||||
async function downloadFont({ id }: { id: string }): Promise<Buffer | null> {
|
||||
const cachePath = join(CACHE_DIR, `${id}.woff2`);
|
||||
|
||||
if (existsSync(cachePath)) {
|
||||
return readFile(cachePath);
|
||||
}
|
||||
|
||||
// Fontsource CDN serves woff2 for all Google fonts
|
||||
const url = `https://cdn.jsdelivr.net/fontsource/fonts/${id}@latest/latin-400-normal.woff2`;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
await writeFile(cachePath, buffer);
|
||||
return buffer;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAllFonts({
|
||||
fonts,
|
||||
concurrency,
|
||||
}: {
|
||||
fonts: FontsourceFont[];
|
||||
concurrency: number;
|
||||
}): Promise<Map<string, Buffer>> {
|
||||
console.log(
|
||||
`Downloading ${fonts.length} font files (${concurrency} concurrent)...`,
|
||||
);
|
||||
const results = new Map<string, Buffer>();
|
||||
let nextIndex = 0;
|
||||
let completed = 0;
|
||||
|
||||
async function worker() {
|
||||
while (nextIndex < fonts.length) {
|
||||
const index = nextIndex++;
|
||||
const font = fonts[index];
|
||||
const buffer = await downloadFont({ id: font.id });
|
||||
if (buffer) results.set(font.id, buffer);
|
||||
completed++;
|
||||
if (completed % 100 === 0 || completed === fonts.length) {
|
||||
process.stdout.write(`\r ${completed}/${fonts.length}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
||||
console.log(`\n Downloaded ${results.size}/${fonts.length} fonts`);
|
||||
return results;
|
||||
}
|
||||
|
||||
function measureFonts({
|
||||
fonts,
|
||||
fontBuffers,
|
||||
}: {
|
||||
fonts: FontsourceFont[];
|
||||
fontBuffers: Map<string, Buffer>;
|
||||
}): MeasuredFont[] {
|
||||
console.log("Registering fonts and measuring text...");
|
||||
const measured: MeasuredFont[] = [];
|
||||
const canvas = createCanvas(CANVAS_WIDTH, ROW_HEIGHT);
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
for (const font of fonts) {
|
||||
const buffer = fontBuffers.get(font.id);
|
||||
if (!buffer) continue;
|
||||
|
||||
try {
|
||||
const ok = GlobalFonts.register(buffer, font.family);
|
||||
if (!ok) continue;
|
||||
|
||||
ctx.font = `${FONT_SIZE}px "${font.family}"`;
|
||||
const metrics = ctx.measureText(font.family);
|
||||
const width = Math.ceil(metrics.width) + WIDTH_BUFFER;
|
||||
|
||||
const styles: string[] = [];
|
||||
for (const weight of font.weights) {
|
||||
if (font.styles.includes("normal")) styles.push(String(weight));
|
||||
if (font.styles.includes("italic")) styles.push(`${weight}i`);
|
||||
}
|
||||
|
||||
measured.push({ family: font.family, width, styles });
|
||||
} catch {
|
||||
// skip fonts that fail to register
|
||||
}
|
||||
}
|
||||
|
||||
measured.sort((a, b) => a.family.localeCompare(b.family));
|
||||
console.log(` ${measured.length} fonts measured`);
|
||||
return measured;
|
||||
}
|
||||
|
||||
function packIntoChunks({ measured }: { measured: MeasuredFont[] }): {
|
||||
atlas: Record<string, AtlasEntry>;
|
||||
chunks: PackedFont[][];
|
||||
} {
|
||||
console.log("Packing into sprite chunks...");
|
||||
const atlas: Record<string, AtlasEntry> = {};
|
||||
const chunks: PackedFont[][] = [[]];
|
||||
let chunkIndex = 0;
|
||||
let cursorX = 0;
|
||||
let cursorY = 0;
|
||||
|
||||
for (const font of measured) {
|
||||
// New row if doesn't fit horizontally
|
||||
if (cursorX + font.width > CANVAS_WIDTH) {
|
||||
cursorX = 0;
|
||||
cursorY += ROW_HEIGHT;
|
||||
}
|
||||
|
||||
// New chunk if doesn't fit vertically
|
||||
if (cursorY + ROW_HEIGHT > MAX_CHUNK_HEIGHT) {
|
||||
chunkIndex++;
|
||||
chunks.push([]);
|
||||
cursorX = 0;
|
||||
cursorY = 0;
|
||||
}
|
||||
|
||||
// Same data goes to BOTH the atlas and the chunk render list
|
||||
const x = cursorX;
|
||||
const y = cursorY;
|
||||
|
||||
atlas[font.family] = {
|
||||
x,
|
||||
y,
|
||||
w: font.width,
|
||||
ch: chunkIndex,
|
||||
s: font.styles,
|
||||
};
|
||||
chunks[chunkIndex].push({ family: font.family, x, y, w: font.width });
|
||||
|
||||
cursorX += font.width + PADDING_X;
|
||||
}
|
||||
|
||||
console.log(` ${chunks.length} chunks`);
|
||||
return { atlas, chunks };
|
||||
}
|
||||
|
||||
async function renderChunks({
|
||||
chunks,
|
||||
}: {
|
||||
chunks: PackedFont[][];
|
||||
}): Promise<void> {
|
||||
console.log("Rendering sprite chunks...");
|
||||
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i];
|
||||
const chunkHeight = Math.max(...chunk.map((f) => f.y)) + ROW_HEIGHT;
|
||||
|
||||
const canvas = createCanvas(CANVAS_WIDTH, chunkHeight);
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
for (const font of chunk) {
|
||||
ctx.font = `${FONT_SIZE}px "${font.family}"`;
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillText(font.family, font.x, font.y + ROW_HEIGHT / 2);
|
||||
}
|
||||
|
||||
const pngBuffer = canvas.toBuffer("image/png");
|
||||
await sharp(pngBuffer)
|
||||
.avif({ quality: 80 })
|
||||
.toFile(join(OUTPUT_DIR, `font-chunk-${i}.avif`));
|
||||
|
||||
console.log(
|
||||
` Chunk ${i}: ${chunk.length} fonts, ${CANVAS_WIDTH}×${chunkHeight}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await mkdir(OUTPUT_DIR, { recursive: true });
|
||||
await mkdir(CACHE_DIR, { recursive: true });
|
||||
|
||||
const fonts = await fetchFontList();
|
||||
const fontBuffers = await downloadAllFonts({
|
||||
fonts,
|
||||
concurrency: CONCURRENT_DOWNLOADS,
|
||||
});
|
||||
const measured = measureFonts({ fonts, fontBuffers });
|
||||
const { atlas, chunks } = packIntoChunks({ measured });
|
||||
await renderChunks({ chunks });
|
||||
|
||||
// Write atlas JSON (compact for smaller file size)
|
||||
await writeFile(
|
||||
join(OUTPUT_DIR, "font-atlas.json"),
|
||||
JSON.stringify({ fonts: atlas }),
|
||||
);
|
||||
|
||||
const totalFonts = Object.keys(atlas).length;
|
||||
console.log(
|
||||
`\nDone! ${totalFonts} fonts in ${chunks.length} chunks → ${OUTPUT_DIR}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Failed:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -14,6 +14,7 @@ import { EditorHeader } from "@/components/editor/editor-header";
|
|||
import { EditorProvider } from "@/components/providers/editor-provider";
|
||||
import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { usePasteMedia } from "@/hooks/use-paste-media";
|
||||
|
||||
export default function Editor() {
|
||||
const params = useParams();
|
||||
|
|
@ -33,6 +34,7 @@ export default function Editor() {
|
|||
}
|
||||
|
||||
function EditorLayout() {
|
||||
usePasteMedia();
|
||||
const { panels, setPanel } = usePanelStore();
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -8,115 +8,115 @@
|
|||
@plugin "tailwindcss-animate";
|
||||
|
||||
:root {
|
||||
--background: hsl(0, 0%, 100%);
|
||||
--foreground: hsl(0 0% 11%);
|
||||
--card: hsl(0, 0%, 100%);
|
||||
--card-foreground: hsl(0 0% 11%);
|
||||
--popover: hsl(0, 0%, 100%);
|
||||
--popover-hover: hsl(0, 0%, 96%);
|
||||
--popover-foreground: hsl(0 0% 2%);
|
||||
--primary: #009dff;
|
||||
--primary-foreground: hsl(0, 0%, 100%);
|
||||
--secondary: hsl(204, 100%, 97%);
|
||||
--secondary-border: hsl(204, 100%, 94%);
|
||||
--secondary-foreground: hsl(200, 98%, 39%);
|
||||
--muted: hsl(0 0% 85.1%);
|
||||
--muted-foreground: hsl(0 0% 50%);
|
||||
--accent: hsl(0, 0%, 96%);
|
||||
--accent-foreground: hsl(0 0% 2%);
|
||||
--destructive: hsl(0, 83%, 50%);
|
||||
--destructive-foreground: hsl(0, 0%, 100%);
|
||||
--constructive: hsl(141, 71%, 48%);
|
||||
--constructive-foreground: hsl(0, 0%, 100%);
|
||||
--border: hsl(0 0% 91%);
|
||||
--input: hsl(0 0% 85.1%);
|
||||
--ring: hsl(0, 0%, 55%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(0 0% 96.1%);
|
||||
--sidebar-foreground: hsl(0 0% 2%);
|
||||
--sidebar-primary: hsl(0 0% 2%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 91%);
|
||||
--sidebar-accent: hsl(0 0% 85.1%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 2%);
|
||||
--sidebar-border: hsl(0 0% 85.1%);
|
||||
--sidebar-ring: hsl(0 0% 16.9%);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
--background: hsl(0, 0%, 100%);
|
||||
--foreground: hsl(0 0% 11%);
|
||||
--card: hsl(0, 0%, 100%);
|
||||
--card-foreground: hsl(0 0% 11%);
|
||||
--popover: hsl(0, 0%, 100%);
|
||||
--popover-hover: hsl(0, 0%, 96%);
|
||||
--popover-foreground: hsl(0 0% 2%);
|
||||
--primary: #009dff;
|
||||
--primary-foreground: hsl(0, 0%, 100%);
|
||||
--secondary: hsl(204, 100%, 97%);
|
||||
--secondary-border: hsl(204, 100%, 94%);
|
||||
--secondary-foreground: hsl(200, 98%, 39%);
|
||||
--muted: hsl(0 0% 85.1%);
|
||||
--muted-foreground: hsl(0 0% 50%);
|
||||
--accent: hsl(0, 0%, 96%);
|
||||
--accent-foreground: hsl(0 0% 2%);
|
||||
--destructive: hsl(0, 83%, 50%);
|
||||
--destructive-foreground: hsl(0, 0%, 100%);
|
||||
--constructive: hsl(141, 71%, 48%);
|
||||
--constructive-foreground: hsl(0, 0%, 100%);
|
||||
--border: hsl(0 0% 91%);
|
||||
--input: hsl(0, 0%, 100%);
|
||||
--ring: hsl(0, 0%, 55%);
|
||||
--chart-1: hsl(220 70% 50%);
|
||||
--chart-2: hsl(160 60% 45%);
|
||||
--chart-3: hsl(30 80% 55%);
|
||||
--chart-4: hsl(280 65% 60%);
|
||||
--chart-5: hsl(340 75% 55%);
|
||||
--sidebar-background: hsl(0 0% 96.1%);
|
||||
--sidebar-foreground: hsl(0 0% 2%);
|
||||
--sidebar-primary: hsl(0 0% 2%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 91%);
|
||||
--sidebar-accent: hsl(0 0% 85.1%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 2%);
|
||||
--sidebar-border: hsl(0 0% 85.1%);
|
||||
--sidebar-ring: hsl(0 0% 16.9%);
|
||||
--sidebar: hsl(0 0% 98%);
|
||||
}
|
||||
|
||||
.panel {
|
||||
--background: hsl(216 13% 98%);
|
||||
--foreground: hsl(0 0% 13%);
|
||||
--card: hsl(0, 0%, 98%);
|
||||
--card-foreground: hsl(0 0% 13%);
|
||||
--primary-foreground: hsl(0, 0%, 98%);
|
||||
--secondary: hsl(204, 100%, 95%);
|
||||
--secondary-border: hsl(204, 100%, 92%);
|
||||
--secondary-foreground: hsl(200, 98%, 37%);
|
||||
--muted: hsl(0 0% 83.1%);
|
||||
--muted-foreground: hsl(0 0% 48%);
|
||||
--accent: hsl(0, 0%, 93%);
|
||||
--accent-foreground: hsl(0 0% 5%);
|
||||
--destructive: hsl(0, 83%, 50%);
|
||||
--destructive-foreground: hsl(0, 0%, 98%);
|
||||
--constructive: hsl(141, 71%, 48%);
|
||||
--constructive-foreground: hsl(0, 0%, 98%);
|
||||
--border: hsl(0 0% 89%);
|
||||
--input: hsl(0 0% 83.1%);
|
||||
--ring: hsl(0, 0%, 53%);
|
||||
--background: hsl(216 13% 98%);
|
||||
--foreground: hsl(0 0% 13%);
|
||||
--card: hsl(0, 0%, 98%);
|
||||
--card-foreground: hsl(0 0% 13%);
|
||||
--primary-foreground: hsl(0, 0%, 98%);
|
||||
--secondary: hsl(204, 100%, 95%);
|
||||
--secondary-border: hsl(204, 100%, 92%);
|
||||
--secondary-foreground: hsl(200, 98%, 37%);
|
||||
--muted: hsl(0 0% 83.1%);
|
||||
--muted-foreground: hsl(0 0% 48%);
|
||||
--accent: hsl(0, 0%, 93%);
|
||||
--accent-foreground: hsl(0 0% 5%);
|
||||
--destructive: hsl(0, 83%, 50%);
|
||||
--destructive-foreground: hsl(0, 0%, 98%);
|
||||
--constructive: hsl(141, 71%, 48%);
|
||||
--constructive-foreground: hsl(0, 0%, 98%);
|
||||
--border: hsl(0 0% 89%);
|
||||
--input: hsl(0 0% 93%);
|
||||
--ring: hsl(0, 0%, 53%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: hsl(0, 0%, 7%);
|
||||
--foreground: hsl(0 0% 87%);
|
||||
--card: hsl(0, 0%, 7%);
|
||||
--card-foreground: hsl(0 0% 87%);
|
||||
--popover: hsl(0, 0%, 16%);
|
||||
--popover-hover: hsl(0, 0%, 22%);
|
||||
--popover-foreground: hsl(0 0% 95%);
|
||||
--secondary: hsl(204, 100%, 12%);
|
||||
--secondary-border: hsl(204, 100%, 15%);
|
||||
--secondary-foreground: hsl(200, 98%, 61%);
|
||||
--muted: hsl(0 0% 20%);
|
||||
--accent: hsl(0, 0%, 14%);
|
||||
--accent-foreground: hsl(0 0% 95%);
|
||||
--constructive: hsl(141, 71%, 52%);
|
||||
--border: hsl(0 0% 16%);
|
||||
--input: hsl(0 0% 20%);
|
||||
--ring: hsl(0, 0%, 50%);
|
||||
--sidebar-background: hsl(0 0% 8%);
|
||||
--sidebar-foreground: hsl(0 0% 95%);
|
||||
--sidebar-primary: hsl(0 0% 95%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 15%);
|
||||
--sidebar-accent: hsl(0 0% 20%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 95%);
|
||||
--sidebar-border: hsl(0 0% 20%);
|
||||
--sidebar-ring: hsl(0 0% 83.1%);
|
||||
--sidebar: hsl(0 0% 6%);
|
||||
--background: hsl(0, 0%, 5%);
|
||||
--foreground: hsl(0 0% 87%);
|
||||
--card: hsl(0, 0%, 5%);
|
||||
--card-foreground: hsl(0 0% 87%);
|
||||
--popover: hsl(0, 0%, 5%);
|
||||
--popover-hover: hsl(0, 0%, 22%);
|
||||
--popover-foreground: hsl(0 0% 95%);
|
||||
--secondary: hsl(204, 100%, 12%);
|
||||
--secondary-border: hsl(204, 100%, 15%);
|
||||
--secondary-foreground: hsl(200, 98%, 61%);
|
||||
--muted: hsl(0 0% 20%);
|
||||
--accent: hsl(0, 0%, 14%);
|
||||
--accent-foreground: hsl(0 0% 95%);
|
||||
--constructive: hsl(141, 71%, 52%);
|
||||
--border: hsl(0 0% 16%);
|
||||
--input: hsl(0 0% 5%);
|
||||
--ring: hsl(0, 0%, 50%);
|
||||
--sidebar-background: hsl(0 0% 8%);
|
||||
--sidebar-foreground: hsl(0 0% 95%);
|
||||
--sidebar-primary: hsl(0 0% 95%);
|
||||
--sidebar-primary-foreground: hsl(0 0% 15%);
|
||||
--sidebar-accent: hsl(0 0% 20%);
|
||||
--sidebar-accent-foreground: hsl(0 0% 95%);
|
||||
--sidebar-border: hsl(0 0% 20%);
|
||||
--sidebar-ring: hsl(0 0% 83.1%);
|
||||
--sidebar: hsl(0 0% 6%);
|
||||
}
|
||||
|
||||
.dark .panel {
|
||||
--background: hsl(0 0% 10%);
|
||||
--foreground: hsl(0 0% 85%);
|
||||
--card: hsl(0, 0%, 10%);
|
||||
--card-foreground: hsl(0 0% 85%);
|
||||
--secondary: hsl(204, 100%, 12%);
|
||||
--secondary-border: hsl(204, 100%, 17%);
|
||||
--secondary-foreground: hsl(200, 98%, 63%);
|
||||
--muted: hsl(0 0% 22%);
|
||||
--accent: hsl(0, 0%, 15%);
|
||||
--accent-foreground: hsl(0 0% 93%);
|
||||
--constructive: hsl(141, 71%, 52%);
|
||||
--border: hsl(0 0% 18%);
|
||||
--input: hsl(0 0% 22%);
|
||||
--ring: hsl(0, 0%, 52%);
|
||||
--background: hsl(0 0% 10%);
|
||||
--foreground: hsl(0 0% 85%);
|
||||
--card: hsl(0, 0%, 10%);
|
||||
--card-foreground: hsl(0 0% 85%);
|
||||
--secondary: hsl(204, 100%, 12%);
|
||||
--secondary-border: hsl(204, 100%, 17%);
|
||||
--secondary-foreground: hsl(200, 98%, 63%);
|
||||
--muted: hsl(0 0% 22%);
|
||||
--accent: hsl(0, 0%, 15%);
|
||||
--accent-foreground: hsl(0 0% 93%);
|
||||
--constructive: hsl(141, 71%, 52%);
|
||||
--border: hsl(0 0% 18%);
|
||||
--input: hsl(0 0% 22%);
|
||||
--ring: hsl(0, 0%, 52%);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
/*
|
||||
/*
|
||||
The default border color has changed to `currentcolor` in Tailwind CSS v4,
|
||||
so we've added these compatibility styles to make sure everything still
|
||||
looks the same as it did with Tailwind CSS v3.
|
||||
|
|
@ -124,166 +124,165 @@
|
|||
If we ever want to remove these styles, we need to add an explicit border
|
||||
color utility to any element that depends on these defaults.
|
||||
*/
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
/* Other default base styles */
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* Prevent back/forward swipe */
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
::selection {
|
||||
@apply bg-primary/35 selection:text-primary-foreground;
|
||||
}
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
/* Other default base styles */
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
/* Prevent back/forward swipe */
|
||||
overscroll-behavior-x: contain;
|
||||
}
|
||||
::selection {
|
||||
@apply bg-primary/35 selection:text-primary-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Responsive breakpoints */
|
||||
--breakpoint-xs: 30rem;
|
||||
/* Responsive breakpoints */
|
||||
--breakpoint-xs: 30rem;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: var(--font-inter), sans-serif;
|
||||
/* Typography */
|
||||
--font-sans: var(--font-inter), sans-serif;
|
||||
|
||||
/* Font sizes */
|
||||
--text-xl: 1.2rem;
|
||||
--text-base: 0.92rem;
|
||||
--text-base--line-height: calc(1.5 / 0.95);
|
||||
--text-xs: 0.75rem;
|
||||
--text-sm: 0.85rem;
|
||||
--text-xs--line-height: calc(1 / 0.8);
|
||||
/* Font sizes */
|
||||
--text-xs: 0.72rem;
|
||||
--text-sm: 0.79rem;
|
||||
--text-base: 0.92rem;
|
||||
--text-base--line-height: calc(1.5 / 0.95);
|
||||
--text-xs--line-height: calc(1 / 0.8);
|
||||
|
||||
/* Border radius */
|
||||
--radius-lg: 0.82rem;
|
||||
--radius-md: 0.65rem;
|
||||
--radius-sm: 0.35rem;
|
||||
/* Border radius */
|
||||
--radius-lg: 0.82rem;
|
||||
--radius-md: 0.65rem;
|
||||
--radius-sm: 0.35rem;
|
||||
|
||||
/* Palette mapped to root design tokens */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
/* Palette mapped to root design tokens */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-hover: var(--popover-hover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-hover: var(--popover-hover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-border: var(--secondary-border);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-border: var(--secondary-border);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
|
||||
--color-constructive: var(--constructive);
|
||||
--color-constructive-foreground: var(--constructive-foreground);
|
||||
--color-constructive: var(--constructive);
|
||||
--color-constructive-foreground: var(--constructive-foreground);
|
||||
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
/* Chart colors */
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
/* Chart colors */
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
|
||||
/* Sidebar */
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
/* Sidebar */
|
||||
--color-sidebar: var(--sidebar-background);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
|
||||
/* Animations */
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
/* Animations */
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
@keyframes accordion-down {
|
||||
from {
|
||||
height: 0;
|
||||
}
|
||||
to {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
@keyframes accordion-up {
|
||||
from {
|
||||
height: var(--radix-accordion-content-height);
|
||||
}
|
||||
to {
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-x-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:horizontal {
|
||||
display: none;
|
||||
}
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:horizontal {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-y-hidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:vertical {
|
||||
display: none;
|
||||
}
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar:vertical {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@utility scrollbar-thin {
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 8px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
&::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 8px;
|
||||
}
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -304,7 +304,6 @@ function ProjectsToolbar({ projectIds }: { projectIds: string[] }) {
|
|||
</Button>
|
||||
</SortDropdown>
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
className="text-muted-foreground"
|
||||
onClick={() =>
|
||||
|
|
@ -827,7 +826,6 @@ function ProjectMenu({
|
|||
<DropdownMenu open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="background"
|
||||
className={
|
||||
isGrid
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { ThemeToggle } from "../theme-toggle";
|
|||
import { DEFAULT_LOGO_URL, SOCIAL_LINKS } from "@/constants/site-constants";
|
||||
import { toast } from "sonner";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { ArrowLeft02Icon, CommandIcon } from "@hugeicons/core-free-icons";
|
||||
import { CommandIcon, Logout05Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ShortcutsDialog } from "./dialogs/shortcuts-dialog";
|
||||
import Image from "next/image";
|
||||
|
|
@ -166,32 +166,30 @@ function ProjectDropdown() {
|
|||
/>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="z-100 w-52">
|
||||
<DropdownMenuContent align="start" className="z-100 w-44">
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={handleExit}
|
||||
disabled={isExiting}
|
||||
icon={<HugeiconsIcon icon={Logout05Icon} />}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowLeft02Icon} className="size-4" />
|
||||
Exit project
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-1.5"
|
||||
onClick={() => setOpenDialog("shortcuts")}
|
||||
icon={<HugeiconsIcon icon={CommandIcon} />}
|
||||
>
|
||||
<HugeiconsIcon icon={CommandIcon} className="size-4" />
|
||||
Keyboard shortcuts
|
||||
Shortcuts
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild icon={<FaDiscord className="!size-4" />}>
|
||||
<Link
|
||||
href={SOCIAL_LINKS.discord}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<FaDiscord className="size-4" />
|
||||
Discord
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,11 @@ import {
|
|||
type ExportQuality,
|
||||
type ExportResult,
|
||||
} from "@/types/export";
|
||||
import { PropertyGroup } from "@/components/editor/panels/properties/property-item";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
} from "@/components/editor/panels/properties/section";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
|
||||
|
||||
|
|
@ -162,78 +166,83 @@ function ExportPopover({
|
|||
{!isExporting && (
|
||||
<>
|
||||
<div className="flex flex-col">
|
||||
<PropertyGroup
|
||||
title="Format"
|
||||
defaultExpanded={false}
|
||||
hasBorderTop={false}
|
||||
>
|
||||
<RadioGroup
|
||||
value={format}
|
||||
onValueChange={(value) => {
|
||||
if (isExportFormat(value)) {
|
||||
setFormat(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="mp4" id="mp4" />
|
||||
<Label htmlFor="mp4">
|
||||
MP4 (H.264) - Better compatibility
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="webm" id="webm" />
|
||||
<Label htmlFor="webm">
|
||||
WebM (VP9) - Smaller file size
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</PropertyGroup>
|
||||
<Section collapsible defaultOpen={false} hasBorderTop={false}>
|
||||
<SectionHeader title="Format" />
|
||||
<SectionContent>
|
||||
<RadioGroup
|
||||
value={format}
|
||||
onValueChange={(value) => {
|
||||
if (isExportFormat(value)) {
|
||||
setFormat(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="mp4" id="mp4" />
|
||||
<Label htmlFor="mp4">
|
||||
MP4 (H.264) - Better compatibility
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="webm" id="webm" />
|
||||
<Label htmlFor="webm">
|
||||
WebM (VP9) - Smaller file size
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
|
||||
<PropertyGroup title="Quality" defaultExpanded={false}>
|
||||
<RadioGroup
|
||||
value={quality}
|
||||
onValueChange={(value) => {
|
||||
if (isExportQuality(value)) {
|
||||
setQuality(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Section collapsible defaultOpen={false}>
|
||||
<SectionHeader title="Quality" />
|
||||
<SectionContent>
|
||||
<RadioGroup
|
||||
value={quality}
|
||||
onValueChange={(value) => {
|
||||
if (isExportQuality(value)) {
|
||||
setQuality(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="low" id="low" />
|
||||
<Label htmlFor="low">Low - Smallest file size</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="medium" id="medium" />
|
||||
<Label htmlFor="medium">Medium - Balanced</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="high" id="high" />
|
||||
<Label htmlFor="high">High - Recommended</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="very_high" id="very_high" />
|
||||
<Label htmlFor="very_high">
|
||||
Very High - Largest file size
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
|
||||
<Section collapsible defaultOpen={false}>
|
||||
<SectionHeader title="Audio" />
|
||||
<SectionContent>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="low" id="low" />
|
||||
<Label htmlFor="low">Low - Smallest file size</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="medium" id="medium" />
|
||||
<Label htmlFor="medium">Medium - Balanced</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="high" id="high" />
|
||||
<Label htmlFor="high">High - Recommended</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="very_high" id="very_high" />
|
||||
<Label htmlFor="very_high">
|
||||
Very High - Largest file size
|
||||
<Checkbox
|
||||
id="include-audio"
|
||||
checked={includeAudio}
|
||||
onCheckedChange={(checked) =>
|
||||
setIncludeAudio(!!checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="include-audio">
|
||||
Include audio in export
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup title="Audio" defaultExpanded={false}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="include-audio"
|
||||
checked={includeAudio}
|
||||
onCheckedChange={(checked) =>
|
||||
setIncludeAudio(!!checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="include-audio">
|
||||
Include audio in export
|
||||
</Label>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<div className="p-3 pt-0">
|
||||
|
|
|
|||
|
|
@ -163,7 +163,9 @@ export function DraggableItem({
|
|||
<div className="size-6 flex-shrink-0 overflow-hidden rounded-[0.35rem]">
|
||||
{preview}
|
||||
</div>
|
||||
<span className="w-full flex-1 truncate text-sm text-left">{name}</span>
|
||||
<span className="w-full flex-1 truncate text-sm text-left">
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { Separator } from "@/components/ui/separator";
|
|||
import { type Tab, useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||
import { TabBar } from "./tabbar";
|
||||
import { Captions } from "./views/captions";
|
||||
import { MediaView } from "./views/media";
|
||||
import { MediaView } from "./views/assets";
|
||||
import { SettingsView } from "./views/settings";
|
||||
import { SoundsView } from "./views/sounds";
|
||||
import { StickersView } from "./views/stickers";
|
||||
|
|
|
|||
|
|
@ -61,7 +61,8 @@ export function TabBar() {
|
|||
aria-label={tab.label}
|
||||
className={cn(
|
||||
"flex-col !p-1.5 !rounded-sm !h-auto [&_svg]:size-4.5",
|
||||
activeTab !== tabKey && "border border-transparent text-muted-foreground",
|
||||
activeTab !== tabKey &&
|
||||
"border border-transparent text-muted-foreground",
|
||||
)}
|
||||
onClick={() => setActiveTab(tabKey)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import Image from "next/image";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
|
||||
import { MediaDragOverlay } from "@/components/editor/panels/assets/drag-overlay";
|
||||
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -29,14 +30,9 @@ import { useEditor } from "@/hooks/use-editor";
|
|||
import { useFileUpload } from "@/hooks/use-file-upload";
|
||||
import { useRevealItem } from "@/hooks/use-reveal-item";
|
||||
import { processMediaAssets } from "@/lib/media/processing";
|
||||
import {
|
||||
buildImageElement,
|
||||
buildUploadAudioElement,
|
||||
buildVideoElement,
|
||||
} from "@/lib/timeline/element-utils";
|
||||
import { buildElementFromMedia } from "@/lib/timeline/element-utils";
|
||||
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||
import type { MediaAsset } from "@/types/assets";
|
||||
import type { CreateTimelineElement } from "@/types/timeline";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
CloudUploadIcon,
|
||||
|
|
@ -132,7 +128,15 @@ export function MediaView() {
|
|||
asset: MediaAsset;
|
||||
startTime: number;
|
||||
}): boolean => {
|
||||
const element = createElementFromMedia({ asset, startTime });
|
||||
const duration =
|
||||
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
const element = buildElementFromMedia({
|
||||
mediaId: asset.id,
|
||||
mediaType: asset.type,
|
||||
name: asset.name,
|
||||
duration,
|
||||
startTime,
|
||||
});
|
||||
editor.timeline.insertElement({
|
||||
element,
|
||||
placement: { mode: "auto" },
|
||||
|
|
@ -194,171 +198,166 @@ export function MediaView() {
|
|||
const renderCompactPreview = (item: MediaAsset) =>
|
||||
previewComponents.get(`compact-${item.id}`);
|
||||
|
||||
const mediaActions = (
|
||||
<div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
setMediaViewMode(mediaViewMode === "grid" ? "list" : "grid")
|
||||
}
|
||||
disabled={isProcessing}
|
||||
className="items-center justify-center"
|
||||
>
|
||||
{mediaViewMode === "grid" ? (
|
||||
<HugeiconsIcon icon={LeftToRightListDashIcon} />
|
||||
) : (
|
||||
<HugeiconsIcon icon={GridViewIcon} />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{mediaViewMode === "grid"
|
||||
? "Switch to list view"
|
||||
: "Switch to grid view"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
<Tooltip>
|
||||
<DropdownMenu>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
disabled={isProcessing}
|
||||
className="items-center justify-center"
|
||||
>
|
||||
<HugeiconsIcon icon={SortingOneNineIcon} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<SortMenuItem
|
||||
label="Name"
|
||||
sortKey="name"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="Type"
|
||||
sortKey="type"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="Duration"
|
||||
sortKey="duration"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="File size"
|
||||
sortKey="size"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Sort by {sortBy} (
|
||||
{sortOrder === "asc" ? "ascending" : "descending"})
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={openFilePicker}
|
||||
disabled={isProcessing}
|
||||
size="sm"
|
||||
className="items-center justify-center gap-1.5 ml-1.5"
|
||||
>
|
||||
<HugeiconsIcon icon={CloudUploadIcon} />
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input {...fileInputProps} />
|
||||
|
||||
<div
|
||||
className={`relative flex h-full flex-col gap-1 ${isDragOver ? "bg-accent/30" : ""}`}
|
||||
<PanelView
|
||||
title="Assets"
|
||||
actions={mediaActions}
|
||||
className={isDragOver ? "bg-accent/30" : ""}
|
||||
{...dragProps}
|
||||
>
|
||||
<div className="bg-background h-12 px-4 pr-2 flex items-center justify-between border-b">
|
||||
<span className="text-muted-foreground text-sm">Assets</span>
|
||||
<div className="flex items-center gap-0">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="text"
|
||||
onClick={() =>
|
||||
setMediaViewMode(
|
||||
mediaViewMode === "grid" ? "list" : "grid",
|
||||
)
|
||||
}
|
||||
disabled={isProcessing}
|
||||
className="items-center justify-center"
|
||||
>
|
||||
{mediaViewMode === "grid" ? (
|
||||
<HugeiconsIcon icon={LeftToRightListDashIcon} />
|
||||
) : (
|
||||
<HugeiconsIcon icon={GridViewIcon} />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{mediaViewMode === "grid"
|
||||
? "Switch to list view"
|
||||
: "Switch to grid view"}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
<Tooltip>
|
||||
<DropdownMenu>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="text"
|
||||
disabled={isProcessing}
|
||||
className="items-center justify-center"
|
||||
>
|
||||
<HugeiconsIcon icon={SortingOneNineIcon} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<SortMenuItem
|
||||
label="Name"
|
||||
sortKey="name"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="Type"
|
||||
sortKey="type"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="Duration"
|
||||
sortKey="duration"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<SortMenuItem
|
||||
label="File size"
|
||||
sortKey="size"
|
||||
currentSortBy={sortBy}
|
||||
currentSortOrder={sortOrder}
|
||||
onSort={({ key }) => {
|
||||
if (sortBy === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Sort by {sortBy} (
|
||||
{sortOrder === "asc" ? "ascending" : "descending"})
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={openFilePicker}
|
||||
disabled={isProcessing}
|
||||
size="sm"
|
||||
className="items-center justify-center gap-1.5 ml-1.5 hover:bg-accent px-3"
|
||||
>
|
||||
<HugeiconsIcon icon={CloudUploadIcon} />
|
||||
Import
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-thin size-full overflow-y-auto pt-1">
|
||||
<div className="w-full flex-1 p-3 pt-0">
|
||||
{isDragOver || filteredMediaItems.length === 0 ? (
|
||||
<MediaDragOverlay
|
||||
isVisible={true}
|
||||
isProcessing={isProcessing}
|
||||
progress={progress}
|
||||
onClick={openFilePicker}
|
||||
/>
|
||||
) : mediaViewMode === "grid" ? (
|
||||
<GridView
|
||||
items={filteredMediaItems}
|
||||
renderPreview={renderPreview}
|
||||
onRemove={handleRemove}
|
||||
onAddToTimeline={addElementAtTime}
|
||||
highlightedId={highlightedId}
|
||||
registerElement={registerElement}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
items={filteredMediaItems}
|
||||
renderPreview={renderCompactPreview}
|
||||
onRemove={handleRemove}
|
||||
onAddToTimeline={addElementAtTime}
|
||||
highlightedId={highlightedId}
|
||||
registerElement={registerElement}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{isDragOver || filteredMediaItems.length === 0 ? (
|
||||
<MediaDragOverlay
|
||||
isVisible={true}
|
||||
isProcessing={isProcessing}
|
||||
progress={progress}
|
||||
onClick={openFilePicker}
|
||||
/>
|
||||
) : mediaViewMode === "grid" ? (
|
||||
<GridView
|
||||
items={filteredMediaItems}
|
||||
renderPreview={renderPreview}
|
||||
onRemove={handleRemove}
|
||||
onAddToTimeline={addElementAtTime}
|
||||
highlightedId={highlightedId}
|
||||
registerElement={registerElement}
|
||||
/>
|
||||
) : (
|
||||
<ListView
|
||||
items={filteredMediaItems}
|
||||
renderPreview={renderCompactPreview}
|
||||
onRemove={handleRemove}
|
||||
onAddToTimeline={addElementAtTime}
|
||||
highlightedId={highlightedId}
|
||||
registerElement={registerElement}
|
||||
/>
|
||||
)}
|
||||
</PanelView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -636,40 +635,3 @@ function SortMenuItem({
|
|||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function createElementFromMedia({
|
||||
asset,
|
||||
startTime,
|
||||
}: {
|
||||
asset: MediaAsset;
|
||||
startTime: number;
|
||||
}): CreateTimelineElement {
|
||||
const duration =
|
||||
asset.duration ?? TIMELINE_CONSTANTS.DEFAULT_ELEMENT_DURATION;
|
||||
|
||||
switch (asset.type) {
|
||||
case "video":
|
||||
return buildVideoElement({
|
||||
mediaId: asset.id,
|
||||
name: asset.name,
|
||||
duration,
|
||||
startTime,
|
||||
});
|
||||
case "image":
|
||||
return buildImageElement({
|
||||
mediaId: asset.id,
|
||||
name: asset.name,
|
||||
duration,
|
||||
startTime,
|
||||
});
|
||||
case "audio":
|
||||
return buildUploadAudioElement({
|
||||
mediaId: asset.id,
|
||||
name: asset.name,
|
||||
duration,
|
||||
startTime,
|
||||
});
|
||||
default:
|
||||
throw new Error(`Unsupported media type: ${asset.type}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { cn } from "@/utils/ui";
|
||||
|
||||
interface PanelViewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
title?: string;
|
||||
actions?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
contentClassName?: string;
|
||||
hideHeader?: boolean;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
onScroll?: React.UIEventHandler<HTMLDivElement>;
|
||||
scrollRef?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export function PanelView({
|
||||
title,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
hideHeader = false,
|
||||
ref,
|
||||
onScroll,
|
||||
scrollRef,
|
||||
...rest
|
||||
}: PanelViewProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("relative flex h-full flex-col", className)}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
>
|
||||
{!hideHeader && (
|
||||
<div className="bg-background h-11 shrink-0 px-4 pr-2 flex items-center justify-between border-b">
|
||||
<span className="text-muted-foreground text-sm">{title}</span>
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"scrollbar-thin size-full overflow-y-auto",
|
||||
hideHeader ? "pt-4" : "pt-2",
|
||||
)}
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
>
|
||||
<div className={cn("w-full flex-1 px-2 pt-0", contentClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
|
||||
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -108,10 +108,7 @@ export function Captions() {
|
|||
};
|
||||
|
||||
return (
|
||||
<BaseView
|
||||
ref={containerRef}
|
||||
className="flex h-full flex-col justify-between"
|
||||
>
|
||||
<PanelView title="Captions" ref={containerRef}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Label>Language</Label>
|
||||
<Select
|
||||
|
|
@ -148,6 +145,6 @@ export function Captions() {
|
|||
{isProcessing ? processingStep : "Generate transcript"}
|
||||
</Button>
|
||||
</div>
|
||||
</BaseView>
|
||||
</PanelView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,332 @@
|
|||
// @ts-nocheck
|
||||
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
BLUR_INTENSITY_PRESETS,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
DEFAULT_COLOR,
|
||||
FPS_PRESETS,
|
||||
} from "@/constants/project-constants";
|
||||
import { patternCraftGradients } from "@/data/colors/pattern-craft";
|
||||
import { colors } from "@/data/colors/solid";
|
||||
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { dimensionToAspectRatio } from "@/utils/geometry";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
PropertyGroup,
|
||||
PropertyItem,
|
||||
PropertyItemLabel,
|
||||
PropertyItemValue,
|
||||
} from "@/components/editor/panels/properties/section";
|
||||
|
||||
export function SettingsView() {
|
||||
return (
|
||||
<PanelView title="Project settings" contentClassName="px-0">
|
||||
<div className="flex flex-col gap-8">
|
||||
<ProjectInfoView />
|
||||
<BackgroundView />
|
||||
</div>
|
||||
</PanelView>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectInfoView() {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const { canvasPresets } = useEditorStore();
|
||||
|
||||
const findPresetIndexByAspectRatio = ({
|
||||
presets,
|
||||
targetAspectRatio,
|
||||
}: {
|
||||
presets: Array<{ width: number; height: number }>;
|
||||
targetAspectRatio: string;
|
||||
}) => {
|
||||
for (let index = 0; index < presets.length; index++) {
|
||||
const preset = presets[index];
|
||||
const presetAspectRatio = dimensionToAspectRatio({
|
||||
width: preset.width,
|
||||
height: preset.height,
|
||||
});
|
||||
if (presetAspectRatio === targetAspectRatio) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
|
||||
const currentCanvasSize = activeProject.settings.canvasSize;
|
||||
const currentAspectRatio = dimensionToAspectRatio(currentCanvasSize);
|
||||
const originalCanvasSize = activeProject.settings.originalCanvasSize ?? null;
|
||||
const presetIndex = findPresetIndexByAspectRatio({
|
||||
presets: canvasPresets,
|
||||
targetAspectRatio: currentAspectRatio,
|
||||
});
|
||||
const originalPresetValue = "original";
|
||||
const selectedPresetValue =
|
||||
presetIndex !== -1 ? presetIndex.toString() : originalPresetValue;
|
||||
|
||||
const handleAspectRatioChange = ({ value }: { value: string }) => {
|
||||
if (value === originalPresetValue) {
|
||||
const canvasSize = originalCanvasSize ?? currentCanvasSize;
|
||||
editor.project.updateSettings({
|
||||
settings: { canvasSize },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const index = parseInt(value, 10);
|
||||
const preset = canvasPresets[index];
|
||||
if (preset) {
|
||||
editor.project.updateSettings({ settings: { canvasSize: preset } });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFpsChange = (value: string) => {
|
||||
const fps = parseFloat(value);
|
||||
editor.project.updateSettings({ settings: { fps } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Name</PropertyItemLabel>
|
||||
<PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Aspect ratio</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<Select
|
||||
value={selectedPresetValue}
|
||||
onValueChange={(value) => handleAspectRatioChange({ value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an aspect ratio" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={originalPresetValue}>Original</SelectItem>
|
||||
{canvasPresets.map((preset, index) => {
|
||||
const label = dimensionToAspectRatio({
|
||||
width: preset.width,
|
||||
height: preset.height,
|
||||
});
|
||||
return (
|
||||
<SelectItem key={label} value={index.toString()}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Frame rate</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<Select
|
||||
value={activeProject.settings.fps.toString()}
|
||||
onValueChange={handleFpsChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a frame rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FPS_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BlurPreview = memo(
|
||||
({
|
||||
blur,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
blur: { label: string; value: number };
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) => (
|
||||
<button
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
|
||||
isSelected && "border-primary border-2",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
aria-label={`Select ${blur.label} blur`}
|
||||
>
|
||||
<Image
|
||||
src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
|
||||
alt={`Blur preview ${blur.label}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
style={{ filter: `blur(${blur.value}px)` }}
|
||||
loading="eager"
|
||||
/>
|
||||
<div className="absolute right-1 bottom-1 left-1 text-center">
|
||||
<span className="rounded bg-black/50 px-1 text-xs text-white">
|
||||
{blur.label}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
),
|
||||
);
|
||||
|
||||
BlurPreview.displayName = "BlurPreview";
|
||||
|
||||
const BackgroundPreviews = memo(
|
||||
({
|
||||
backgrounds,
|
||||
currentBackgroundColor,
|
||||
isColorBackground,
|
||||
handleColorSelect,
|
||||
useBackgroundColor = false,
|
||||
}: {
|
||||
backgrounds: string[];
|
||||
currentBackgroundColor: string;
|
||||
isColorBackground: boolean;
|
||||
handleColorSelect: ({ bg }: { bg: string }) => void;
|
||||
useBackgroundColor?: boolean;
|
||||
}) => {
|
||||
return useMemo(
|
||||
() =>
|
||||
backgrounds.map((bg, index) => (
|
||||
<button
|
||||
key={`${index}-${bg}`}
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
|
||||
isColorBackground &&
|
||||
bg === currentBackgroundColor &&
|
||||
"border-primary border-2",
|
||||
)}
|
||||
style={
|
||||
useBackgroundColor
|
||||
? { backgroundColor: bg }
|
||||
: {
|
||||
background: bg,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}
|
||||
}
|
||||
onClick={() => handleColorSelect({ bg })}
|
||||
type="button"
|
||||
aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`}
|
||||
/>
|
||||
)),
|
||||
[
|
||||
backgrounds,
|
||||
isColorBackground,
|
||||
currentBackgroundColor,
|
||||
handleColorSelect,
|
||||
useBackgroundColor,
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BackgroundPreviews.displayName = "BackgroundPreviews";
|
||||
|
||||
function BackgroundView() {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []);
|
||||
|
||||
const handleBlurSelect = useCallback(
|
||||
async ({ blurIntensity }: { blurIntensity: number }) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "blur", blurIntensity } },
|
||||
});
|
||||
},
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const handleColorSelect = useCallback(
|
||||
async ({ color }: { color: string }) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "color", color } },
|
||||
});
|
||||
},
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const currentBlurIntensity =
|
||||
activeProject.settings.background.type === "blur"
|
||||
? activeProject.settings.background.blurIntensity
|
||||
: DEFAULT_BLUR_INTENSITY;
|
||||
|
||||
const currentBackgroundColor =
|
||||
activeProject.settings.background.type === "color"
|
||||
? activeProject.settings.background.color
|
||||
: DEFAULT_COLOR;
|
||||
|
||||
const isBlurBackground = activeProject.settings.background.type === "blur";
|
||||
const isColorBackground = activeProject.settings.background.type === "color";
|
||||
|
||||
const blurPreviews = useMemo(
|
||||
() =>
|
||||
blurLevels.map((blur) => (
|
||||
<BlurPreview
|
||||
key={blur.value}
|
||||
blur={blur}
|
||||
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
|
||||
onSelect={() => handleBlurSelect({ blurIntensity: blur.value })}
|
||||
/>
|
||||
)),
|
||||
[blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect],
|
||||
);
|
||||
|
||||
const backgroundSections = [
|
||||
{ title: "Colors", backgrounds: colors, useBackgroundColor: true },
|
||||
{ title: "Pattern craft", backgrounds: patternCraftGradients },
|
||||
{ title: "Syntax UI", backgrounds: syntaxUIGradients },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}>
|
||||
<div className="flex flex-wrap gap-2">{blurPreviews}</div>
|
||||
</PropertyGroup>
|
||||
|
||||
{backgroundSections.map((section) => (
|
||||
<PropertyGroup
|
||||
key={section.title}
|
||||
title={section.title}
|
||||
defaultExpanded={false}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BackgroundPreviews
|
||||
backgrounds={section.backgrounds}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
|
||||
useBackgroundColor={section.useBackgroundColor}
|
||||
/>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { memo, useCallback, useMemo } from "react";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
|
||||
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
|
|
@ -10,63 +8,54 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
BLUR_INTENSITY_PRESETS,
|
||||
DEFAULT_BLUR_INTENSITY,
|
||||
DEFAULT_COLOR,
|
||||
FPS_PRESETS,
|
||||
} from "@/constants/project-constants";
|
||||
import { patternCraftGradients } from "@/data/colors/pattern-craft";
|
||||
import { colors } from "@/data/colors/solid";
|
||||
import { syntaxUIGradients } from "@/data/colors/syntax-ui";
|
||||
import { FPS_PRESETS } from "@/constants/project-constants";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useEditorStore } from "@/stores/editor-store";
|
||||
import { dimensionToAspectRatio } from "@/utils/geometry";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
PropertyGroup,
|
||||
PropertyItem,
|
||||
PropertyItemLabel,
|
||||
PropertyItemValue,
|
||||
} from "@/components/editor/panels/properties/property-item";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionHeader,
|
||||
} from "@/components/editor/panels/properties/section";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { useState } from "react";
|
||||
|
||||
export function SettingsView() {
|
||||
return <ProjectSettingsTabs />;
|
||||
}
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
function ProjectSettingsTabs() {
|
||||
return (
|
||||
<BaseView
|
||||
defaultTab="project-info"
|
||||
tabs={[
|
||||
{
|
||||
value: "project-info",
|
||||
label: "Project info",
|
||||
content: (
|
||||
<div className="p-5">
|
||||
<ProjectInfoView />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "background",
|
||||
label: "Background",
|
||||
content: (
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<div className="flex-1">
|
||||
<BackgroundView />
|
||||
<PanelView contentClassName="px-0" hideHeader>
|
||||
<div className="flex flex-col">
|
||||
<Section hasBorderTop={false}>
|
||||
<SectionContent>
|
||||
<ProjectInfoContent />
|
||||
</SectionContent>
|
||||
</Section>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Section className="cursor-pointer">
|
||||
<PopoverTrigger asChild>
|
||||
<div>
|
||||
<SectionHeader title="Background">
|
||||
<div className="size-4 rounded-sm bg-red-500" />
|
||||
</SectionHeader>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
className="flex h-full flex-col justify-between p-0"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
</Section>
|
||||
<PopoverContent>
|
||||
<div className="size-4 rounded-sm bg-red-500" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</PanelView>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectInfoView() {
|
||||
function ProjectInfoContent() {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const { canvasPresets } = useEditorStore();
|
||||
|
|
@ -124,232 +113,55 @@ function ProjectInfoView() {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Name</PropertyItemLabel>
|
||||
<PropertyItemValue>{activeProject.metadata.name}</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Aspect ratio</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<Select
|
||||
value={selectedPresetValue}
|
||||
onValueChange={(value) => handleAspectRatioChange({ value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select an aspect ratio" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={originalPresetValue}>Original</SelectItem>
|
||||
{canvasPresets.map((preset, index) => {
|
||||
const label = dimensionToAspectRatio({
|
||||
width: preset.width,
|
||||
height: preset.height,
|
||||
});
|
||||
return (
|
||||
<SelectItem key={label} value={index.toString()}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Frame rate</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<Select
|
||||
value={activeProject.settings.fps.toString()}
|
||||
onValueChange={handleFpsChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a frame rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FPS_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const BlurPreview = memo(
|
||||
({
|
||||
blur,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
blur: { label: string; value: number };
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) => (
|
||||
<button
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary relative aspect-square size-20 cursor-pointer overflow-hidden rounded-sm border",
|
||||
isSelected && "border-primary border-2",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
type="button"
|
||||
aria-label={`Select ${blur.label} blur`}
|
||||
>
|
||||
<Image
|
||||
src="https://images.unsplash.com/photo-1501785888041-af3ef285b470?q=80&w=1470&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D"
|
||||
alt={`Blur preview ${blur.label}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
style={{ filter: `blur(${blur.value}px)` }}
|
||||
loading="eager"
|
||||
/>
|
||||
<div className="absolute right-1 bottom-1 left-1 text-center">
|
||||
<span className="rounded bg-black/50 px-1 text-xs text-white">
|
||||
{blur.label}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Name</Label>
|
||||
<span className="leading-none text-sm">
|
||||
{activeProject.metadata.name}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
),
|
||||
);
|
||||
|
||||
BlurPreview.displayName = "BlurPreview";
|
||||
|
||||
const BackgroundPreviews = memo(
|
||||
({
|
||||
backgrounds,
|
||||
currentBackgroundColor,
|
||||
isColorBackground,
|
||||
handleColorSelect,
|
||||
useBackgroundColor = false,
|
||||
}: {
|
||||
backgrounds: string[];
|
||||
currentBackgroundColor: string;
|
||||
isColorBackground: boolean;
|
||||
handleColorSelect: ({ bg }: { bg: string }) => void;
|
||||
useBackgroundColor?: boolean;
|
||||
}) => {
|
||||
return useMemo(
|
||||
() =>
|
||||
backgrounds.map((bg, index) => (
|
||||
<button
|
||||
key={`${index}-${bg}`}
|
||||
className={cn(
|
||||
"border-foreground/15 hover:border-primary aspect-square size-20 cursor-pointer rounded-sm border",
|
||||
isColorBackground &&
|
||||
bg === currentBackgroundColor &&
|
||||
"border-primary border-2",
|
||||
)}
|
||||
style={
|
||||
useBackgroundColor
|
||||
? { backgroundColor: bg }
|
||||
: {
|
||||
background: bg,
|
||||
backgroundSize: "cover",
|
||||
backgroundPosition: "center",
|
||||
backgroundRepeat: "no-repeat",
|
||||
}
|
||||
}
|
||||
onClick={() => handleColorSelect({ bg })}
|
||||
type="button"
|
||||
aria-label={`Select background ${useBackgroundColor ? bg : index + 1}`}
|
||||
/>
|
||||
)),
|
||||
[
|
||||
backgrounds,
|
||||
isColorBackground,
|
||||
currentBackgroundColor,
|
||||
handleColorSelect,
|
||||
useBackgroundColor,
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
BackgroundPreviews.displayName = "BackgroundPreviews";
|
||||
|
||||
function BackgroundView() {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const blurLevels = useMemo(() => BLUR_INTENSITY_PRESETS, []);
|
||||
|
||||
const handleBlurSelect = useCallback(
|
||||
async ({ blurIntensity }: { blurIntensity: number }) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "blur", blurIntensity } },
|
||||
});
|
||||
},
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const handleColorSelect = useCallback(
|
||||
async ({ color }: { color: string }) => {
|
||||
await editor.project.updateSettings({
|
||||
settings: { background: { type: "color", color } },
|
||||
});
|
||||
},
|
||||
[editor.project],
|
||||
);
|
||||
|
||||
const currentBlurIntensity =
|
||||
activeProject.settings.background.type === "blur"
|
||||
? activeProject.settings.background.blurIntensity
|
||||
: DEFAULT_BLUR_INTENSITY;
|
||||
|
||||
const currentBackgroundColor =
|
||||
activeProject.settings.background.type === "color"
|
||||
? activeProject.settings.background.color
|
||||
: DEFAULT_COLOR;
|
||||
|
||||
const isBlurBackground = activeProject.settings.background.type === "blur";
|
||||
const isColorBackground = activeProject.settings.background.type === "color";
|
||||
|
||||
const blurPreviews = useMemo(
|
||||
() =>
|
||||
blurLevels.map((blur) => (
|
||||
<BlurPreview
|
||||
key={blur.value}
|
||||
blur={blur}
|
||||
isSelected={isBlurBackground && currentBlurIntensity === blur.value}
|
||||
onSelect={() => handleBlurSelect({ blurIntensity: blur.value })}
|
||||
/>
|
||||
)),
|
||||
[blurLevels, isBlurBackground, currentBlurIntensity, handleBlurSelect],
|
||||
);
|
||||
|
||||
const backgroundSections = [
|
||||
{ title: "Colors", backgrounds: colors, useBackgroundColor: true },
|
||||
{ title: "Pattern craft", backgrounds: patternCraftGradients },
|
||||
{ title: "Syntax UI", backgrounds: syntaxUIGradients },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PropertyGroup title="Blur" hasBorderTop={false} defaultExpanded={false}>
|
||||
<div className="flex flex-wrap gap-2">{blurPreviews}</div>
|
||||
</PropertyGroup>
|
||||
|
||||
{backgroundSections.map((section) => (
|
||||
<PropertyGroup
|
||||
key={section.title}
|
||||
title={section.title}
|
||||
defaultExpanded={false}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Aspect ratio</Label>
|
||||
<Select
|
||||
value={selectedPresetValue}
|
||||
onValueChange={(value) => handleAspectRatioChange({ value })}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<BackgroundPreviews
|
||||
backgrounds={section.backgrounds}
|
||||
currentBackgroundColor={currentBackgroundColor}
|
||||
isColorBackground={isColorBackground}
|
||||
handleColorSelect={({ bg }) => handleColorSelect({ color: bg })}
|
||||
useBackgroundColor={section.useBackgroundColor}
|
||||
/>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
))}
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder="Select an aspect ratio" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={originalPresetValue}>Original</SelectItem>
|
||||
{canvasPresets.map((preset, index) => {
|
||||
const label = dimensionToAspectRatio({
|
||||
width: preset.width,
|
||||
height: preset.height,
|
||||
});
|
||||
return (
|
||||
<SelectItem key={label} value={index.toString()}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Frame rate</Label>
|
||||
<Select
|
||||
value={activeProject.settings.fps.toString()}
|
||||
onValueChange={handleFpsChange}
|
||||
>
|
||||
<SelectTrigger className="w-fit">
|
||||
<SelectValue placeholder="Select a frame rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FPS_PRESETS.map((preset) => (
|
||||
<SelectItem key={preset.value} value={preset.value}>
|
||||
{preset.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -235,7 +235,7 @@ function SoundEffectsView() {
|
|||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
placeholder="Search sound effects"
|
||||
className="bg-accent w-full"
|
||||
className="w-full"
|
||||
containerClassName="w-full"
|
||||
value={searchQuery}
|
||||
onChange={({ currentTarget }) =>
|
||||
|
|
|
|||
|
|
@ -4,144 +4,104 @@ import Image from "next/image";
|
|||
import type { CSSProperties } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
|
||||
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { InputWithBack } from "@/components/ui/input-with-back";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { STICKER_CATEGORIES } from "@/constants/stickers-constants";
|
||||
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||
import {
|
||||
buildIconSvgUrl,
|
||||
getIconSvgUrl,
|
||||
ICONIFY_HOSTS,
|
||||
POPULAR_COLLECTIONS,
|
||||
} from "@/lib/iconify-api";
|
||||
resolveStickerId,
|
||||
type StickerItem as StickerData,
|
||||
} from "@/lib/stickers";
|
||||
import { useStickersStore } from "@/stores/stickers-store";
|
||||
import type { StickerCategory } from "@/types/stickers";
|
||||
import { cn } from "@/utils/ui";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
HappyIcon,
|
||||
ClockIcon,
|
||||
LayoutGridIcon,
|
||||
MultiplicationSignIcon,
|
||||
SparklesIcon,
|
||||
HashtagIcon,
|
||||
Search01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
|
||||
function isStickerCategory(value: string): value is StickerCategory {
|
||||
return STICKER_CATEGORIES.includes(value as StickerCategory);
|
||||
}
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { OcSlidersVerticalIcon } from "@opencut/ui/icons";
|
||||
import type { StickerCategory } from "@/types/stickers";
|
||||
import { STICKER_CATEGORIES } from "@/constants/sticker-constants";
|
||||
import { parseStickerId } from "@/lib/stickers/sticker-id";
|
||||
|
||||
export function StickersView() {
|
||||
const { selectedCategory, setSelectedCategory } = useStickersStore();
|
||||
|
||||
return (
|
||||
<BaseView
|
||||
value={selectedCategory}
|
||||
onValueChange={(v) => {
|
||||
if (isStickerCategory(v)) {
|
||||
setSelectedCategory({ category: v });
|
||||
}
|
||||
}}
|
||||
tabs={[
|
||||
{
|
||||
value: "all",
|
||||
label: "All",
|
||||
icon: <HugeiconsIcon icon={LayoutGridIcon} className="size-3" />,
|
||||
content: <StickersContentView category="all" />,
|
||||
},
|
||||
{
|
||||
value: "general",
|
||||
label: "Icons",
|
||||
icon: <HugeiconsIcon icon={SparklesIcon} className="size-3" />,
|
||||
content: <StickersContentView category="general" />,
|
||||
},
|
||||
{
|
||||
value: "brands",
|
||||
label: "Brands",
|
||||
icon: <HugeiconsIcon icon={HashtagIcon} className="size-3" />,
|
||||
content: <StickersContentView category="brands" />,
|
||||
},
|
||||
{
|
||||
value: "emoji",
|
||||
label: "Emoji",
|
||||
icon: <HugeiconsIcon icon={HappyIcon} className="size-3" />,
|
||||
content: <StickersContentView category="emoji" />,
|
||||
},
|
||||
]}
|
||||
className="flex h-full flex-col overflow-hidden p-0"
|
||||
/>
|
||||
<PanelView
|
||||
title="Stickers"
|
||||
actions={
|
||||
<div className="flex items-center">
|
||||
<Select
|
||||
value={selectedCategory}
|
||||
onValueChange={(value: StickerCategory) =>
|
||||
setSelectedCategory({ category: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger variant="outline" size="sm" className="mr-1.5">
|
||||
<SelectValue placeholder="All" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(STICKER_CATEGORIES).map(([category, label]) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button variant="ghost" size="icon">
|
||||
<HugeiconsIcon icon={Search01Icon} className="!size-3.5" />
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="icon">
|
||||
<OcSlidersVerticalIcon className="!size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<StickersContentView />
|
||||
</PanelView>
|
||||
);
|
||||
}
|
||||
|
||||
function StickerGrid({
|
||||
icons,
|
||||
onAdd,
|
||||
addingSticker,
|
||||
capSize = false,
|
||||
items,
|
||||
shouldCapSize = false,
|
||||
}: {
|
||||
icons: string[];
|
||||
onAdd: (iconName: string) => void;
|
||||
addingSticker: string | null;
|
||||
capSize?: boolean;
|
||||
items: StickerData[];
|
||||
shouldCapSize?: boolean;
|
||||
}) {
|
||||
const gridStyle: CSSProperties & {
|
||||
"--sticker-min": string;
|
||||
"--sticker-max"?: string;
|
||||
} = {
|
||||
gridTemplateColumns: capSize
|
||||
gridTemplateColumns: shouldCapSize
|
||||
? "repeat(auto-fill, minmax(var(--sticker-min, 96px), var(--sticker-max, 160px)))"
|
||||
: "repeat(auto-fit, minmax(var(--sticker-min, 96px), 1fr))",
|
||||
"--sticker-min": "96px",
|
||||
...(capSize ? { "--sticker-max": "160px" } : {}),
|
||||
...(shouldCapSize ? { "--sticker-max": "160px" } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid gap-2" style={gridStyle}>
|
||||
{icons.map((iconName) => (
|
||||
<StickerItem
|
||||
key={iconName}
|
||||
iconName={iconName}
|
||||
onAdd={onAdd}
|
||||
isAdding={addingSticker === iconName}
|
||||
capSize={capSize}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionGrid({
|
||||
collections,
|
||||
onSelectCollection,
|
||||
}: {
|
||||
collections: Array<{
|
||||
prefix: string;
|
||||
name: string;
|
||||
total: number;
|
||||
category?: string;
|
||||
}>;
|
||||
onSelectCollection: ({ prefix }: { prefix: string }) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{collections.map((collection) => (
|
||||
<CollectionItem
|
||||
key={collection.prefix}
|
||||
title={collection.name}
|
||||
subtitle={`${collection.total.toLocaleString()} icons${collection.category ? ` • ${collection.category}` : ""}`}
|
||||
onClick={() => onSelectCollection({ prefix: collection.prefix })}
|
||||
/>
|
||||
{items.map((item) => (
|
||||
<StickerItem key={item.id} item={item} shouldCapSize={shouldCapSize} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -162,372 +122,125 @@ function EmptyView({ message }: { message: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function StickersContentView({ category }: { category: StickerCategory }) {
|
||||
function StickersContentView() {
|
||||
const {
|
||||
searchQuery,
|
||||
selectedCollection,
|
||||
viewMode,
|
||||
collections,
|
||||
currentCollection,
|
||||
searchResults,
|
||||
recentStickers,
|
||||
isLoadingCollections,
|
||||
isLoadingCollection,
|
||||
isSearching,
|
||||
setSearchQuery,
|
||||
setSelectedCollection,
|
||||
loadCollections,
|
||||
searchStickers,
|
||||
addStickerToTimeline,
|
||||
clearRecentStickers,
|
||||
setSelectedCategory,
|
||||
addingSticker,
|
||||
} = useStickersStore();
|
||||
|
||||
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
|
||||
const [collectionsToShow, setCollectionsToShow] = useState(20);
|
||||
const [showCollectionItems, setShowCollectionItems] = useState(false);
|
||||
|
||||
const filteredCollections = useMemo(() => {
|
||||
if (category === "all") {
|
||||
return Object.entries(collections).map(([prefix, collection]) => ({
|
||||
prefix,
|
||||
name: collection.name,
|
||||
total: collection.total,
|
||||
category: collection.category,
|
||||
}));
|
||||
const itemsToDisplay = useMemo(() => {
|
||||
if (viewMode === "search" && searchResults) {
|
||||
return searchResults.items;
|
||||
}
|
||||
|
||||
const collectionList =
|
||||
POPULAR_COLLECTIONS[category as keyof typeof POPULAR_COLLECTIONS];
|
||||
if (!collectionList) return [];
|
||||
return [];
|
||||
}, [viewMode, searchResults]);
|
||||
|
||||
return collectionList
|
||||
.map((c) => {
|
||||
const collection = collections[c.prefix];
|
||||
return collection
|
||||
? {
|
||||
prefix: c.prefix,
|
||||
name: c.name,
|
||||
total: collection.total,
|
||||
}
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean) as Array<{
|
||||
prefix: string;
|
||||
name: string;
|
||||
total: number;
|
||||
}>;
|
||||
}, [collections, category]);
|
||||
|
||||
const { scrollAreaRef, handleScroll } = useInfiniteScroll({
|
||||
onLoadMore: () => setCollectionsToShow((prev) => prev + 20),
|
||||
hasMore: filteredCollections.length > collectionsToShow,
|
||||
isLoading: isLoadingCollections,
|
||||
enabled: viewMode === "browse" && !selectedCollection && category === "all",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(collections).length === 0) {
|
||||
loadCollections();
|
||||
}
|
||||
}, [collections, loadCollections]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (localSearchQuery !== searchQuery) {
|
||||
setSearchQuery({ query: localSearchQuery });
|
||||
if (localSearchQuery.trim()) {
|
||||
searchStickers({ query: localSearchQuery });
|
||||
}
|
||||
const recentStickerItems = useMemo(() => {
|
||||
const items: StickerData[] = [];
|
||||
for (const stickerId of recentStickers) {
|
||||
const recentStickerItem = toRecentStickerItem({ stickerId });
|
||||
if (recentStickerItem) {
|
||||
items.push(recentStickerItem);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
return items;
|
||||
}, [recentStickers]);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [localSearchQuery, searchQuery, searchStickers, setSearchQuery]);
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
{recentStickerItems.length > 0 && viewMode === "browse" && (
|
||||
<div className="flex h-full flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<HugeiconsIcon
|
||||
icon={ClockIcon}
|
||||
className="text-muted-foreground size-4"
|
||||
/>
|
||||
<span className="text-sm font-medium">Recent</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearRecentStickers}
|
||||
className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={MultiplicationSignIcon}
|
||||
className="text-muted-foreground size-3"
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Clear recent stickers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<StickerGrid items={recentStickerItems.slice(0, 12)} shouldCapSize />
|
||||
</div>
|
||||
)}
|
||||
|
||||
const handleAddSticker = async (iconName: string) => {
|
||||
{viewMode === "search" && (
|
||||
<div className="h-full">
|
||||
{isSearching ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
) : searchResults?.items.length ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{searchResults.total} results
|
||||
</span>
|
||||
</div>
|
||||
<StickerGrid items={itemsToDisplay} shouldCapSize />
|
||||
</div>
|
||||
) : searchQuery ? (
|
||||
<EmptyView message={`No stickers found for "${searchQuery}"`} />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface StickerItemProps {
|
||||
item: StickerData;
|
||||
shouldCapSize?: boolean;
|
||||
}
|
||||
|
||||
function StickerItem({ item, shouldCapSize = false }: StickerItemProps) {
|
||||
const { addingSticker, addStickerToTimeline } = useStickersStore();
|
||||
const isAdding = addingSticker === item.id;
|
||||
const [hasImageError, setHasImageError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!item.id) {
|
||||
return;
|
||||
}
|
||||
setHasImageError(false);
|
||||
}, [item.id]);
|
||||
|
||||
const displayName = item.name;
|
||||
|
||||
const handleAdd = async () => {
|
||||
try {
|
||||
await addStickerToTimeline({ iconName });
|
||||
await addStickerToTimeline({
|
||||
stickerId: item.id,
|
||||
name: item.name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to add sticker:", error);
|
||||
toast.error("Failed to add sticker to timeline");
|
||||
}
|
||||
};
|
||||
|
||||
const iconsToDisplay = useMemo(() => {
|
||||
if (viewMode === "search" && searchResults) {
|
||||
return searchResults.icons;
|
||||
}
|
||||
|
||||
if (viewMode === "collection" && currentCollection) {
|
||||
const icons: string[] = [];
|
||||
|
||||
if (currentCollection.uncategorized) {
|
||||
icons.push(
|
||||
...currentCollection.uncategorized.map(
|
||||
(name) => `${currentCollection.prefix}:${name}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (currentCollection.categories) {
|
||||
Object.values(currentCollection.categories).forEach((categoryIcons) => {
|
||||
icons.push(
|
||||
...categoryIcons.map(
|
||||
(name) => `${currentCollection.prefix}:${name}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return icons.slice(0, 100);
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [viewMode, searchResults, currentCollection]);
|
||||
|
||||
const isInCollection = viewMode === "collection" && !!selectedCollection;
|
||||
|
||||
useEffect(() => {
|
||||
if (isInCollection) {
|
||||
setShowCollectionItems(false);
|
||||
const timer = setTimeout(() => setShowCollectionItems(true), 350);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setShowCollectionItems(false);
|
||||
}
|
||||
}, [isInCollection]);
|
||||
|
||||
return (
|
||||
<div className="mt-1 flex h-full flex-col gap-5 p-4">
|
||||
<div className="space-y-3">
|
||||
<InputWithBack
|
||||
isExpanded={isInCollection}
|
||||
setIsExpanded={(expanded) => {
|
||||
if (!expanded && isInCollection) {
|
||||
setSelectedCollection({ collection: null });
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
category === "all"
|
||||
? "Search all stickers"
|
||||
: category === "general"
|
||||
? "Search icons"
|
||||
: category === "brands"
|
||||
? "Search brands"
|
||||
: "Search Emojis"
|
||||
}
|
||||
value={localSearchQuery}
|
||||
onChange={setLocalSearchQuery}
|
||||
disableAnimation={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative h-full overflow-hidden">
|
||||
<ScrollArea
|
||||
className="h-full flex-1"
|
||||
ref={scrollAreaRef}
|
||||
onScrollCapture={handleScroll}
|
||||
>
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
{recentStickers.length > 0 && viewMode === "browse" && (
|
||||
<div className="h-full">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<HugeiconsIcon
|
||||
icon={ClockIcon}
|
||||
className="text-muted-foreground size-4"
|
||||
/>
|
||||
<span className="text-sm font-medium">Recent</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearRecentStickers}
|
||||
className="hover:bg-accent ml-auto flex size-5 items-center justify-center rounded p-0"
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={MultiplicationSignIcon}
|
||||
className="text-muted-foreground size-3"
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Clear recent stickers</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<StickerGrid
|
||||
icons={recentStickers.slice(0, 12)}
|
||||
onAdd={handleAddSticker}
|
||||
addingSticker={addingSticker}
|
||||
capSize
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "collection" && selectedCollection && (
|
||||
<div className="h-full">
|
||||
{isLoadingCollection ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
) : showCollectionItems ? (
|
||||
<StickerGrid
|
||||
icons={iconsToDisplay}
|
||||
onAdd={handleAddSticker}
|
||||
addingSticker={addingSticker}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "search" && (
|
||||
<div className="h-full">
|
||||
{isSearching ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
) : searchResults?.icons.length ? (
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{searchResults.total} results
|
||||
</span>
|
||||
</div>
|
||||
<StickerGrid
|
||||
icons={iconsToDisplay}
|
||||
onAdd={handleAddSticker}
|
||||
addingSticker={addingSticker}
|
||||
capSize
|
||||
/>
|
||||
</>
|
||||
) : searchQuery ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-8">
|
||||
<EmptyView
|
||||
message={`No stickers found for "${searchQuery}"`}
|
||||
/>
|
||||
{category !== "all" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const q = localSearchQuery || searchQuery;
|
||||
if (q) {
|
||||
setSearchQuery({ query: q });
|
||||
}
|
||||
setSelectedCategory({ category: "all" });
|
||||
if (q) {
|
||||
searchStickers({ query: q });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Search in all icons
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "browse" && !selectedCollection && (
|
||||
<div className="h-full space-y-4">
|
||||
{isLoadingCollections ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Spinner className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{category !== "all" && (
|
||||
<div className="h-full">
|
||||
<CollectionGrid
|
||||
collections={filteredCollections}
|
||||
onSelectCollection={({ prefix }) =>
|
||||
setSelectedCollection({ collection: prefix })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{category === "all" && filteredCollections.length > 0 && (
|
||||
<div className="h-full">
|
||||
<CollectionGrid
|
||||
collections={filteredCollections.slice(
|
||||
0,
|
||||
collectionsToShow,
|
||||
)}
|
||||
onSelectCollection={({ prefix }) =>
|
||||
setSelectedCollection({ collection: prefix })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CollectionItemProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function CollectionItem({ title, subtitle, onClick }: CollectionItemProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-auto justify-between rounded-md py-2"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="text-left">
|
||||
<p className="font-medium">{title}</p>
|
||||
<p className="text-muted-foreground text-xs">{subtitle}</p>
|
||||
</div>
|
||||
<HugeiconsIcon icon={ArrowRightIcon} className="size-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
interface StickerItemProps {
|
||||
iconName: string;
|
||||
onAdd: (iconName: string) => void;
|
||||
isAdding?: boolean;
|
||||
capSize?: boolean;
|
||||
}
|
||||
|
||||
function StickerItem({
|
||||
iconName,
|
||||
onAdd,
|
||||
isAdding,
|
||||
capSize = false,
|
||||
}: StickerItemProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
const [hostIndex, setHostIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!iconName) {
|
||||
return;
|
||||
}
|
||||
setImageError(false);
|
||||
setHostIndex(0);
|
||||
}, [iconName]);
|
||||
|
||||
const displayName = iconName.split(":")[1] || iconName;
|
||||
const collectionPrefix = iconName.split(":")[0];
|
||||
|
||||
const preview = imageError ? (
|
||||
const preview = hasImageError ? (
|
||||
<div className="flex size-full items-center justify-center p-2">
|
||||
<span className="text-muted-foreground text-center text-xs break-all">
|
||||
{displayName}
|
||||
|
|
@ -536,21 +249,13 @@ function StickerItem({
|
|||
) : (
|
||||
<div className="flex size-full items-center justify-center p-4">
|
||||
<Image
|
||||
src={
|
||||
hostIndex === 0
|
||||
? getIconSvgUrl(iconName, { width: 64, height: 64 })
|
||||
: buildIconSvgUrl(
|
||||
ICONIFY_HOSTS[Math.min(hostIndex, ICONIFY_HOSTS.length - 1)],
|
||||
iconName,
|
||||
{ width: 64, height: 64 },
|
||||
)
|
||||
}
|
||||
src={item.previewUrl}
|
||||
alt={displayName}
|
||||
width={64}
|
||||
height={64}
|
||||
className="size-full object-contain"
|
||||
style={
|
||||
capSize
|
||||
shouldCapSize
|
||||
? {
|
||||
maxWidth: "var(--sticker-max, 160px)",
|
||||
maxHeight: "var(--sticker-max, 160px)",
|
||||
|
|
@ -558,12 +263,7 @@ function StickerItem({
|
|||
: undefined
|
||||
}
|
||||
onError={() => {
|
||||
const next = hostIndex + 1;
|
||||
if (next < ICONIFY_HOSTS.length) {
|
||||
setHostIndex(next);
|
||||
} else {
|
||||
setImageError(true);
|
||||
}
|
||||
setHasImageError(true);
|
||||
}}
|
||||
loading="lazy"
|
||||
unoptimized
|
||||
|
|
@ -572,44 +272,63 @@ function StickerItem({
|
|||
);
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"relative",
|
||||
isAdding && "pointer-events-none opacity-50",
|
||||
)}
|
||||
>
|
||||
<DraggableItem
|
||||
name={displayName}
|
||||
preview={preview}
|
||||
dragData={{
|
||||
id: iconName,
|
||||
type: "sticker",
|
||||
name: displayName,
|
||||
iconName,
|
||||
}}
|
||||
onAddToTimeline={() => onAdd(iconName)}
|
||||
aspectRatio={1}
|
||||
shouldShowLabel={false}
|
||||
isRounded={true}
|
||||
variant="card"
|
||||
className=""
|
||||
containerClassName="w-full"
|
||||
/>
|
||||
{isAdding && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
|
||||
<Spinner className="size-6 text-white" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn("relative", isAdding && "pointer-events-none opacity-50")}
|
||||
>
|
||||
<DraggableItem
|
||||
name={displayName}
|
||||
preview={preview}
|
||||
dragData={{
|
||||
id: item.id,
|
||||
type: "sticker",
|
||||
name: displayName,
|
||||
stickerId: item.id,
|
||||
}}
|
||||
onAddToTimeline={handleAdd}
|
||||
aspectRatio={1}
|
||||
shouldShowLabel={false}
|
||||
isRounded
|
||||
variant="card"
|
||||
containerClassName="w-full"
|
||||
/>
|
||||
{isAdding && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-md bg-black/60">
|
||||
<Spinner className="size-6 text-white" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">{displayName}</p>
|
||||
<p className="text-muted-foreground text-xs">{collectionPrefix}</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getStickerNameFromId({ stickerId }: { stickerId: string }): string {
|
||||
const stickerIdParts = stickerId.split(":");
|
||||
if (stickerIdParts.length <= 1) {
|
||||
return stickerId;
|
||||
}
|
||||
return (
|
||||
stickerIdParts.slice(1).join(":").split(":").pop()?.replaceAll("-", " ") ??
|
||||
stickerId
|
||||
);
|
||||
}
|
||||
|
||||
function toRecentStickerItem({
|
||||
stickerId,
|
||||
}: {
|
||||
stickerId: string;
|
||||
}): StickerData | null {
|
||||
try {
|
||||
const { providerId } = parseStickerId({ stickerId });
|
||||
return {
|
||||
id: stickerId,
|
||||
provider: providerId,
|
||||
name: getStickerNameFromId({ stickerId }),
|
||||
previewUrl: resolveStickerId({
|
||||
stickerId,
|
||||
options: { width: 64, height: 64 },
|
||||
}),
|
||||
metadata: {},
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { DraggableItem } from "@/components/editor/panels/assets/draggable-item";
|
||||
import { PanelBaseView as BaseView } from "@/components/editor/panels/panel-base-view";
|
||||
import { PanelView } from "@/components/editor/panels/assets/views/base-view";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_TEXT_ELEMENT } from "@/constants/text-constants";
|
||||
import { buildTextElement } from "@/lib/timeline/element-utils";
|
||||
|
|
@ -23,7 +23,7 @@ export function TextView() {
|
|||
};
|
||||
|
||||
return (
|
||||
<BaseView>
|
||||
<PanelView title="Text">
|
||||
<DraggableItem
|
||||
name="Default text"
|
||||
preview={
|
||||
|
|
@ -41,6 +41,6 @@ export function TextView() {
|
|||
onAddToTimeline={handleAddToTimeline}
|
||||
shouldShowLabel={false}
|
||||
/>
|
||||
</BaseView>
|
||||
</PanelView>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,85 +0,0 @@
|
|||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
interface PanelBaseViewProps {
|
||||
children?: React.ReactNode;
|
||||
defaultTab?: string;
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
tabs?: {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
}[];
|
||||
className?: string;
|
||||
ref?: React.Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
function ViewContent({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<ScrollArea className="flex-1 scrollbar-hidden">
|
||||
<div className={cn("p-5", className)}>{children}</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export function PanelBaseView({
|
||||
children,
|
||||
defaultTab,
|
||||
value,
|
||||
onValueChange,
|
||||
tabs,
|
||||
className = "",
|
||||
ref,
|
||||
}: PanelBaseViewProps) {
|
||||
return (
|
||||
<div className={cn("flex h-full flex-col", className)} ref={ref}>
|
||||
{!tabs || tabs.length === 0 ? (
|
||||
<ViewContent className={className}>{children}</ViewContent>
|
||||
) : (
|
||||
<Tabs
|
||||
defaultValue={defaultTab}
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
className="flex h-full flex-col"
|
||||
>
|
||||
<div className="bg-background sticky top-0 z-10">
|
||||
<div className="px-3 pt-3 pb-0">
|
||||
<TabsList>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.value} value={tab.value}>
|
||||
{tab.icon ? (
|
||||
<span className="mr-1 inline-flex items-center">
|
||||
{tab.icon}
|
||||
</span>
|
||||
) : null}
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
<Separator className="mt-3" />
|
||||
</div>
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
className="mt-0 flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<ViewContent className={className}>{tab.content}</ViewContent>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { findCurrentScene } from "@/lib/scenes";
|
||||
import { getBookmarksActiveAtTime } from "@/lib/timeline/bookmarks";
|
||||
|
||||
export function BookmarkNoteOverlay() {
|
||||
const editor = useEditor();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const activeProject = editor.project.getActive();
|
||||
|
||||
if (!activeProject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeScene = findCurrentScene({
|
||||
scenes: activeProject.scenes,
|
||||
currentSceneId: activeProject.currentSceneId,
|
||||
});
|
||||
|
||||
if (!activeScene) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bookmarks = activeScene.bookmarks;
|
||||
const activeBookmarks = getBookmarksActiveAtTime({
|
||||
bookmarks,
|
||||
time: currentTime,
|
||||
});
|
||||
const bookmarksWithNotes = activeBookmarks.filter(
|
||||
(bookmark) => bookmark.note != null && bookmark.note.trim() !== "",
|
||||
);
|
||||
|
||||
if (bookmarksWithNotes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute top-2 left-2 flex flex-col gap-1.5"
|
||||
aria-live="polite"
|
||||
>
|
||||
{bookmarksWithNotes.map((bookmark) => (
|
||||
<div
|
||||
key={bookmark.time}
|
||||
className="flex max-w-[min(200px,50vw)] px-2.5 py-1.5 text-left text-white text-xs shadow-md backdrop-blur-sm"
|
||||
style={{
|
||||
backgroundColor: "rgb(0 0 0 / 0.5)",
|
||||
borderLeft: bookmark.color
|
||||
? `3px solid ${bookmark.color}`
|
||||
: "3px solid var(--primary)",
|
||||
}}
|
||||
>
|
||||
{bookmark.note}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { usePreviewStore } from "@/stores/preview-store";
|
||||
|
||||
export function PreviewContextMenu({
|
||||
onToggleFullscreen,
|
||||
containerRef,
|
||||
}: {
|
||||
onToggleFullscreen: () => void;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const { overlays, setOverlayVisibility } = usePreviewStore();
|
||||
|
||||
return (
|
||||
<ContextMenuContent className="w-56" container={containerRef.current}>
|
||||
<ContextMenuItem onClick={onToggleFullscreen} inset>
|
||||
Full screen
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => editor.renderer.saveSnapshot()} inset>
|
||||
Save snapshot
|
||||
</ContextMenuItem>
|
||||
<ContextMenuCheckboxItem
|
||||
checked={overlays.bookmarks}
|
||||
onCheckedChange={(checked) =>
|
||||
setOverlayVisibility({ overlay: "bookmarks", isVisible: !!checked })
|
||||
}
|
||||
>
|
||||
Show bookmarks
|
||||
</ContextMenuCheckboxItem>
|
||||
<ContextMenuItem inset>Show grid</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,18 +9,13 @@ import { useFullscreen } from "@/hooks/use-fullscreen";
|
|||
import { CanvasRenderer } from "@/services/renderer/canvas-renderer";
|
||||
import type { RootNode } from "@/services/renderer/nodes/root-node";
|
||||
import { buildScene } from "@/services/renderer/scene-builder";
|
||||
import { formatTimeCode, getLastFrameTime } from "@/lib/time";
|
||||
import { getLastFrameTime } from "@/lib/time";
|
||||
import { PreviewInteractionOverlay } from "./preview-interaction-overlay";
|
||||
import { EditableTimecode } from "@/components/editable-timecode";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
FullScreenIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { BookmarkNoteOverlay } from "./bookmark-note-overlay";
|
||||
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
|
||||
import { usePreviewStore } from "@/stores/preview-store";
|
||||
import { PreviewContextMenu } from "./context-menu";
|
||||
import { PreviewToolbar } from "./toolbar";
|
||||
|
||||
function usePreviewSize() {
|
||||
const editor = useEditor();
|
||||
|
|
@ -32,6 +27,30 @@ function usePreviewSize() {
|
|||
};
|
||||
}
|
||||
|
||||
export function PreviewPanel() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="panel bg-background relative flex size-full min-h-0 min-w-0 flex-col rounded-sm border"
|
||||
>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0">
|
||||
<PreviewCanvas
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
<RenderTreeController />
|
||||
</div>
|
||||
<PreviewToolbar
|
||||
isFullscreen={isFullscreen}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderTreeController() {
|
||||
const editor = useEditor();
|
||||
const tracks = editor.timeline.getTracks();
|
||||
|
|
@ -50,6 +69,7 @@ function RenderTreeController() {
|
|||
duration,
|
||||
canvasSize: { width, height },
|
||||
background: activeProject.settings.background,
|
||||
isPreview: true,
|
||||
});
|
||||
|
||||
editor.renderer.setRenderTree({ renderTree });
|
||||
|
|
@ -58,98 +78,24 @@ function RenderTreeController() {
|
|||
return null;
|
||||
}
|
||||
|
||||
export function PreviewPanel() {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { isFullscreen, toggleFullscreen } = useFullscreen({ containerRef });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"panel bg-background relative flex h-full min-h-0 w-full min-w-0 flex-col rounded-sm border",
|
||||
isFullscreen && "bg-background",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 items-center justify-center p-2 pb-0">
|
||||
<PreviewCanvas />
|
||||
<RenderTreeController />
|
||||
</div>
|
||||
<PreviewToolbar
|
||||
isFullscreen={isFullscreen}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewToolbar({
|
||||
isFullscreen,
|
||||
function PreviewCanvas({
|
||||
onToggleFullscreen,
|
||||
containerRef,
|
||||
}: {
|
||||
isFullscreen: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const isPlaying = editor.playback.getIsPlaying();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const totalDuration = editor.timeline.getTotalDuration();
|
||||
const fps = editor.project.getActive().settings.fps;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
|
||||
<div className="flex items-center mt-1">
|
||||
<EditableTimecode
|
||||
time={currentTime}
|
||||
duration={totalDuration}
|
||||
format="HH:MM:SS:FF"
|
||||
fps={fps}
|
||||
onTimeChange={({ time }) => editor.playback.seek({ time })}
|
||||
className="text-center"
|
||||
/>
|
||||
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{formatTimeCode({
|
||||
timeInSeconds: totalDuration,
|
||||
format: "HH:MM:SS:FF",
|
||||
fps,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => invokeAction("toggle-play")}
|
||||
>
|
||||
<HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
|
||||
</Button>
|
||||
|
||||
<div className="justify-self-end">
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={onToggleFullscreen}
|
||||
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
||||
>
|
||||
<HugeiconsIcon icon={FullScreenIcon} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewCanvas() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const outerContainerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasBoundsRef = useRef<HTMLDivElement>(null);
|
||||
const lastFrameRef = useRef(-1);
|
||||
const lastSceneRef = useRef<RootNode | null>(null);
|
||||
const renderingRef = useRef(false);
|
||||
const { width: nativeWidth, height: nativeHeight } = usePreviewSize();
|
||||
const containerSize = useContainerSize({ containerRef });
|
||||
const containerSize = useContainerSize({ containerRef: outerContainerRef });
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const { overlays } = usePreviewStore();
|
||||
|
||||
const renderer = useMemo(() => {
|
||||
return new CanvasRenderer({
|
||||
|
|
@ -224,24 +170,42 @@ function PreviewCanvas() {
|
|||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative flex h-full w-full items-center justify-center"
|
||||
ref={outerContainerRef}
|
||||
className="relative flex size-full items-center justify-center"
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={nativeWidth}
|
||||
height={nativeHeight}
|
||||
className="block border"
|
||||
style={{
|
||||
width: displaySize.width,
|
||||
height: displaySize.height,
|
||||
background:
|
||||
activeProject.settings.background.type === "blur"
|
||||
? "transparent"
|
||||
: activeProject?.settings.background.color,
|
||||
}}
|
||||
/>
|
||||
<PreviewInteractionOverlay canvasRef={canvasRef} />
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
ref={canvasBoundsRef}
|
||||
className="relative"
|
||||
style={{ width: displaySize.width, height: displaySize.height }}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={nativeWidth}
|
||||
height={nativeHeight}
|
||||
className="block border"
|
||||
style={{
|
||||
width: displaySize.width,
|
||||
height: displaySize.height,
|
||||
background:
|
||||
activeProject.settings.background.type === "blur"
|
||||
? "transparent"
|
||||
: activeProject?.settings.background.color,
|
||||
}}
|
||||
/>
|
||||
<PreviewInteractionOverlay
|
||||
canvasRef={canvasRef}
|
||||
containerRef={canvasBoundsRef}
|
||||
/>
|
||||
{overlays.bookmarks && <BookmarkNoteOverlay />}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<PreviewContextMenu
|
||||
onToggleFullscreen={onToggleFullscreen}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { usePreviewStore } from "@/stores/preview-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 } = usePreviewStore();
|
||||
|
||||
if (layoutGuide.platform === null) return null;
|
||||
if (layoutGuide.platform === "tiktok") return <TikTokGuide />;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,20 +1,54 @@
|
|||
import { usePreviewInteraction } from "@/hooks/use-preview-interaction";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { TransformHandles } from "./transform-handles";
|
||||
import { SnapGuides } from "./snap-guides";
|
||||
import { TextEditOverlay } from "./text-edit-overlay";
|
||||
|
||||
export function PreviewInteractionOverlay({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
}: {
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const { onPointerDown, onPointerMove, onPointerUp } =
|
||||
usePreviewInteraction({ canvasRef });
|
||||
const {
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onDoubleClick,
|
||||
snapLines,
|
||||
editingText,
|
||||
commitTextEdit,
|
||||
cancelTextEdit,
|
||||
} = usePreviewInteraction({ canvasRef });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("absolute inset-0 pointer-events-auto")}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
/>
|
||||
<div className="absolute inset-0">
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: canvas overlay, pointer-only interaction */}
|
||||
<div
|
||||
className="absolute inset-0 pointer-events-auto"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onDoubleClick={onDoubleClick}
|
||||
/>
|
||||
{editingText ? (
|
||||
<TextEditOverlay
|
||||
canvasRef={canvasRef}
|
||||
containerRef={containerRef}
|
||||
trackId={editingText.trackId}
|
||||
elementId={editingText.elementId}
|
||||
element={editingText.element}
|
||||
onCommit={commitTextEdit}
|
||||
onCancel={cancelTextEdit}
|
||||
/>
|
||||
) : (
|
||||
<TransformHandles canvasRef={canvasRef} containerRef={containerRef} />
|
||||
)}
|
||||
<SnapGuides
|
||||
lines={snapLines}
|
||||
canvasRef={canvasRef}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
"use client";
|
||||
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import type { SnapLine } from "@/lib/preview/preview-snap";
|
||||
import { positionToOverlay } from "@/lib/preview/preview-coords";
|
||||
|
||||
export function SnapGuides({
|
||||
lines,
|
||||
canvasRef,
|
||||
containerRef,
|
||||
}: {
|
||||
lines: SnapLine[];
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||||
const containerRect = containerRef.current?.getBoundingClientRect();
|
||||
|
||||
if (!canvasRect || !containerRect || lines.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const toOverlayX = (logicalX: number) =>
|
||||
positionToOverlay({
|
||||
positionX: logicalX,
|
||||
positionY: 0,
|
||||
canvasRect,
|
||||
containerRect,
|
||||
canvasSize,
|
||||
}).x;
|
||||
|
||||
const toOverlayY = (logicalY: number) =>
|
||||
positionToOverlay({
|
||||
positionX: 0,
|
||||
positionY: logicalY,
|
||||
canvasRect,
|
||||
containerRect,
|
||||
canvasSize,
|
||||
}).y;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0" aria-hidden>
|
||||
{lines.map((line) => {
|
||||
if (line.type === "vertical") {
|
||||
return (
|
||||
<div
|
||||
key={`vertical-${line.position}`}
|
||||
className="absolute top-0 bottom-0 w-px bg-white/70"
|
||||
style={{ left: toOverlayX(line.position) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={`horizontal-${line.position}`}
|
||||
className="absolute left-0 right-0 h-px bg-white/70"
|
||||
style={{ top: toOverlayY(line.position) }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import type { TextElement } from "@/types/timeline";
|
||||
import {
|
||||
positionToOverlay,
|
||||
getDisplayScale,
|
||||
} from "@/lib/preview/preview-coords";
|
||||
import {
|
||||
DEFAULT_LINE_HEIGHT,
|
||||
FONT_SIZE_SCALE_REFERENCE,
|
||||
} from "@/constants/text-constants";
|
||||
|
||||
const TEXT_BACKGROUND_PADDING = "4px 8px";
|
||||
const TEXT_EDIT_VERTICAL_OFFSET_EM = 0.06;
|
||||
|
||||
export function TextEditOverlay({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
trackId,
|
||||
elementId,
|
||||
element,
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: {
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
trackId: string;
|
||||
elementId: string;
|
||||
element: TextElement;
|
||||
onCommit: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const div = divRef.current;
|
||||
if (!div) return;
|
||||
div.focus();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(div);
|
||||
const selection = window.getSelection();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
}, []);
|
||||
|
||||
const handleInput = useCallback(() => {
|
||||
const div = divRef.current;
|
||||
if (!div) return;
|
||||
const text = div.innerText;
|
||||
editor.timeline.previewElements({
|
||||
updates: [{ trackId, elementId, updates: { content: text } }],
|
||||
});
|
||||
}, [editor.timeline, trackId, elementId]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
const { key } = event;
|
||||
if (key === "Escape") {
|
||||
event.preventDefault();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
},
|
||||
[onCancel],
|
||||
);
|
||||
|
||||
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||||
const containerRect = containerRef.current?.getBoundingClientRect();
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
|
||||
if (!canvasRect || !containerRect || !canvasSize) return null;
|
||||
|
||||
const { x: posX, y: posY } = positionToOverlay({
|
||||
positionX: element.transform.position.x,
|
||||
positionY: element.transform.position.y,
|
||||
canvasRect,
|
||||
containerRect,
|
||||
canvasSize,
|
||||
});
|
||||
|
||||
const { x: displayScaleX } = getDisplayScale({
|
||||
canvasRect,
|
||||
canvasSize,
|
||||
});
|
||||
|
||||
const displayFontSize =
|
||||
element.fontSize *
|
||||
(canvasSize.height / FONT_SIZE_SCALE_REFERENCE) *
|
||||
displayScaleX;
|
||||
|
||||
const verticalAlignmentOffset =
|
||||
displayFontSize * TEXT_EDIT_VERTICAL_OFFSET_EM;
|
||||
|
||||
const lineHeight = element.lineHeight ?? DEFAULT_LINE_HEIGHT;
|
||||
const fontWeight = element.fontWeight === "bold" ? "bold" : "normal";
|
||||
const fontStyle = element.fontStyle === "italic" ? "italic" : "normal";
|
||||
const letterSpacing = element.letterSpacing ?? 0;
|
||||
const backgroundColor =
|
||||
element.backgroundColor === "transparent"
|
||||
? "transparent"
|
||||
: element.backgroundColor;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
left: posX,
|
||||
top: posY - verticalAlignmentOffset,
|
||||
transform: `translate(-50%, -50%) scale(${element.transform.scale}) rotate(${element.transform.rotate}deg)`,
|
||||
transformOrigin: "center center",
|
||||
}}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: contenteditable required for multiline, IME, paste */}
|
||||
<div
|
||||
ref={divRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
tabIndex={0}
|
||||
role="textbox"
|
||||
aria-label="Edit text"
|
||||
className="cursor-text select-text outline-none whitespace-pre"
|
||||
style={{
|
||||
fontSize: displayFontSize,
|
||||
fontFamily: element.fontFamily,
|
||||
fontWeight,
|
||||
fontStyle,
|
||||
textAlign: element.textAlign,
|
||||
letterSpacing: `${letterSpacing}px`,
|
||||
lineHeight,
|
||||
color: element.color,
|
||||
backgroundColor,
|
||||
minHeight: displayFontSize * lineHeight,
|
||||
textDecoration: element.textDecoration ?? "none",
|
||||
padding:
|
||||
backgroundColor === "transparent" ? 0 : TEXT_BACKGROUND_PADDING,
|
||||
minWidth: 1,
|
||||
}}
|
||||
onInput={handleInput}
|
||||
onBlur={onCommit}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{element.content || ""}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
"use client";
|
||||
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { formatTimeCode } from "@/lib/time";
|
||||
import { invokeAction } from "@/lib/actions";
|
||||
import { EditableTimecode } from "@/components/editable-timecode";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
FullScreenIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { OcSocialIcon } from "@opencut/ui/icons";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
export function PreviewToolbar({
|
||||
isFullscreen,
|
||||
onToggleFullscreen,
|
||||
}: {
|
||||
isFullscreen: boolean;
|
||||
onToggleFullscreen: () => void;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const isPlaying = editor.playback.getIsPlaying();
|
||||
const currentTime = editor.playback.getCurrentTime();
|
||||
const totalDuration = editor.timeline.getTotalDuration();
|
||||
const fps = editor.project.getActive().settings.fps;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-center pb-3 pt-5 px-5">
|
||||
<div className="flex items-center">
|
||||
<EditableTimecode
|
||||
time={currentTime}
|
||||
duration={totalDuration}
|
||||
format="HH:MM:SS:FF"
|
||||
fps={fps}
|
||||
onTimeChange={({ time }) => editor.playback.seek({ time })}
|
||||
className="text-center"
|
||||
/>
|
||||
<span className="text-muted-foreground px-2 font-mono text-xs">/</span>
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{formatTimeCode({
|
||||
timeInSeconds: totalDuration,
|
||||
format: "HH:MM:SS:FF",
|
||||
fps,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={() => invokeAction("toggle-play")}
|
||||
>
|
||||
<HugeiconsIcon icon={isPlaying ? PauseIcon : PlayIcon} />
|
||||
</Button>
|
||||
|
||||
<div className="justify-self-end flex items-center gap-2.5">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="[&_svg]:size-auto px-1 h-7"
|
||||
onClick={onToggleFullscreen}
|
||||
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
||||
>
|
||||
<OcSocialIcon size={20} />
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<Button
|
||||
variant="text"
|
||||
onClick={onToggleFullscreen}
|
||||
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
|
||||
>
|
||||
<HugeiconsIcon icon={FullScreenIcon} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
"use client";
|
||||
|
||||
import { useTransformHandles } from "@/hooks/use-transform-handles";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { isVisualElement } from "@/lib/timeline/element-utils";
|
||||
import { SnapGuides } from "./snap-guides";
|
||||
import { canvasToOverlay, getDisplayScale } from "@/lib/preview/preview-coords";
|
||||
import type { ElementBounds } from "@/lib/preview/element-bounds";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Rotate01Icon } from "@hugeicons/core-free-icons";
|
||||
|
||||
const HANDLE_SIZE = 10;
|
||||
const ROTATION_HANDLE_OFFSET = 24;
|
||||
const ROTATION_HANDLE_RADIUS = 10;
|
||||
const CORNER_HIT_AREA_SIZE = 18;
|
||||
|
||||
type Corner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
const CORNERS: Corner[] = [
|
||||
"top-left",
|
||||
"top-right",
|
||||
"bottom-left",
|
||||
"bottom-right",
|
||||
];
|
||||
|
||||
function getCornerPosition({
|
||||
bounds,
|
||||
corner,
|
||||
}: {
|
||||
bounds: ElementBounds;
|
||||
corner: Corner;
|
||||
}): { x: number; y: number } {
|
||||
const halfW = bounds.width / 2;
|
||||
const halfH = bounds.height / 2;
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
|
||||
const localX =
|
||||
corner === "top-left" || corner === "bottom-left" ? -halfW : halfW;
|
||||
const localY =
|
||||
corner === "top-left" || corner === "top-right" ? -halfH : halfH;
|
||||
|
||||
return {
|
||||
x: bounds.cx + (localX * cos - localY * sin),
|
||||
y: bounds.cy + (localX * sin + localY * cos),
|
||||
};
|
||||
}
|
||||
|
||||
function getRotationHandlePosition({ bounds }: { bounds: ElementBounds }): {
|
||||
x: number;
|
||||
y: number;
|
||||
} {
|
||||
const angleRad = (bounds.rotation * Math.PI) / 180;
|
||||
const cos = Math.cos(angleRad);
|
||||
const sin = Math.sin(angleRad);
|
||||
const localY = -bounds.height / 2 - ROTATION_HANDLE_OFFSET;
|
||||
return {
|
||||
x: bounds.cx - localY * sin,
|
||||
y: bounds.cy + localY * cos,
|
||||
};
|
||||
}
|
||||
|
||||
function getOverlayContext({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
}: {
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}): {
|
||||
canvasRect: DOMRect;
|
||||
containerRect: DOMRect;
|
||||
} | null {
|
||||
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||||
const containerRect = containerRef.current?.getBoundingClientRect();
|
||||
if (!canvasRect || !containerRect) return null;
|
||||
return { canvasRect, containerRect };
|
||||
}
|
||||
|
||||
export function TransformHandles({
|
||||
canvasRef,
|
||||
containerRef,
|
||||
}: {
|
||||
canvasRef: React.RefObject<HTMLCanvasElement | null>;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const {
|
||||
selectedWithBounds,
|
||||
hasVisualSelection,
|
||||
snapLines,
|
||||
handleCornerPointerDown,
|
||||
handleRotationPointerDown,
|
||||
handlePointerMove,
|
||||
handlePointerUp,
|
||||
} = useTransformHandles({ canvasRef });
|
||||
|
||||
const editor = useEditor();
|
||||
const canvasSize = editor.project.getActive().settings.canvasSize;
|
||||
|
||||
if (!hasVisualSelection || !selectedWithBounds) return null;
|
||||
|
||||
const overlayContext = getOverlayContext({ canvasRef, containerRef });
|
||||
if (!overlayContext) return null;
|
||||
|
||||
const { bounds, element } = selectedWithBounds;
|
||||
if (!isVisualElement(element)) return null;
|
||||
|
||||
const { canvasRect, containerRect } = overlayContext;
|
||||
const displayScale = getDisplayScale({ canvasRect, canvasSize });
|
||||
|
||||
const toOverlay = ({
|
||||
canvasX,
|
||||
canvasY,
|
||||
}: {
|
||||
canvasX: number;
|
||||
canvasY: number;
|
||||
}) =>
|
||||
canvasToOverlay({
|
||||
canvasX,
|
||||
canvasY,
|
||||
canvasRect,
|
||||
containerRect,
|
||||
canvasSize,
|
||||
});
|
||||
|
||||
const center = toOverlay({ canvasX: bounds.cx, canvasY: bounds.cy });
|
||||
const outlineWidth = bounds.width * displayScale.x;
|
||||
const outlineHeight = bounds.height * displayScale.y;
|
||||
|
||||
const rotationHandle = getRotationHandlePosition({ bounds });
|
||||
const rotationHandleScreen = toOverlay({
|
||||
canvasX: rotationHandle.x,
|
||||
canvasY: rotationHandle.y,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-0" aria-hidden>
|
||||
<SnapGuides
|
||||
lines={snapLines}
|
||||
canvasRef={canvasRef}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
<BoundingBoxOutline
|
||||
center={center}
|
||||
outlineWidth={outlineWidth}
|
||||
outlineHeight={outlineHeight}
|
||||
rotation={bounds.rotation}
|
||||
/>
|
||||
{CORNERS.map((corner) => {
|
||||
const cornerPosition = getCornerPosition({ bounds, corner });
|
||||
const screen = toOverlay({
|
||||
canvasX: cornerPosition.x,
|
||||
canvasY: cornerPosition.y,
|
||||
});
|
||||
return (
|
||||
<CornerHandle
|
||||
key={corner}
|
||||
corner={corner}
|
||||
screen={screen}
|
||||
onPointerDown={(event) =>
|
||||
handleCornerPointerDown({ event, corner })
|
||||
}
|
||||
onPointerMove={(event) => handlePointerMove({ event })}
|
||||
onPointerUp={(event) => handlePointerUp({ event })}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<RotationHandle
|
||||
screen={rotationHandleScreen}
|
||||
onPointerDown={(event) => handleRotationPointerDown({ event })}
|
||||
onPointerMove={(event) => handlePointerMove({ event })}
|
||||
onPointerUp={(event) => handlePointerUp({ event })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BoundingBoxOutline({
|
||||
center,
|
||||
outlineWidth,
|
||||
outlineHeight,
|
||||
rotation,
|
||||
}: {
|
||||
center: { x: number; y: number };
|
||||
outlineWidth: number;
|
||||
outlineHeight: number;
|
||||
rotation: number;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
left: center.x - outlineWidth / 2,
|
||||
top: center.y - outlineHeight / 2,
|
||||
width: outlineWidth,
|
||||
height: outlineHeight,
|
||||
transform: `rotate(${rotation}deg)`,
|
||||
transformOrigin: "center center",
|
||||
border: "1px solid white",
|
||||
boxSizing: "border-box",
|
||||
opacity: 0.75,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CornerHandle({
|
||||
corner,
|
||||
screen,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
corner: Corner;
|
||||
screen: { x: number; y: number };
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
const isNesw = corner === "top-right" || corner === "bottom-left";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"absolute flex items-center justify-center outline-none",
|
||||
isNesw ? "cursor-nesw-resize" : "cursor-nwse-resize",
|
||||
)}
|
||||
style={{
|
||||
left: screen.x - CORNER_HIT_AREA_SIZE / 2,
|
||||
top: screen.y - CORNER_HIT_AREA_SIZE / 2,
|
||||
width: CORNER_HIT_AREA_SIZE,
|
||||
height: CORNER_HIT_AREA_SIZE,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
|
||||
onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
|
||||
>
|
||||
<div
|
||||
className="rounded-sm bg-white"
|
||||
style={{ width: HANDLE_SIZE, height: HANDLE_SIZE }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RotationHandle({
|
||||
screen,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
}: {
|
||||
screen: { x: number; y: number };
|
||||
onPointerDown: (event: React.PointerEvent) => void;
|
||||
onPointerMove: (event: React.PointerEvent) => void;
|
||||
onPointerUp: (event: React.PointerEvent) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute flex items-center justify-center rounded-full bg-white text-black shadow-sm outline-none"
|
||||
style={{
|
||||
left: screen.x - ROTATION_HANDLE_RADIUS,
|
||||
top: screen.y - ROTATION_HANDLE_RADIUS,
|
||||
width: ROTATION_HANDLE_RADIUS * 2,
|
||||
height: ROTATION_HANDLE_RADIUS * 2,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerLeave={onPointerUp}
|
||||
onKeyDown={(event) => event.key === "Enter" && event.preventDefault()}
|
||||
onKeyUp={(event) => event.key === "Enter" && event.preventDefault()}
|
||||
>
|
||||
<HugeiconsIcon icon={Rotate01Icon} className="size-3" strokeWidth={2.5} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,4 +17,4 @@ export function EmptyView() {
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import { useReducer, useRef } from "react";
|
||||
import { evaluateMathExpression } from "@/utils/math";
|
||||
|
||||
function looksLikeExpression({ input }: { input: string }): boolean {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/[+*/]/.test(input)) return true;
|
||||
const minusIndex = trimmed.indexOf("-");
|
||||
return minusIndex > 0;
|
||||
}
|
||||
|
||||
export function usePropertyDraft<T>({
|
||||
displayValue: sourceDisplay,
|
||||
parse,
|
||||
onPreview,
|
||||
onCommit,
|
||||
supportsExpressions = true,
|
||||
}: {
|
||||
displayValue: string;
|
||||
parse: (input: string) => T | null;
|
||||
onPreview: (value: T) => void;
|
||||
onCommit: () => void;
|
||||
supportsExpressions?: boolean;
|
||||
}) {
|
||||
const [, forceRender] = useReducer(
|
||||
(renderVersion: number) => renderVersion + 1,
|
||||
0,
|
||||
);
|
||||
const isEditing = useRef(false);
|
||||
const draft = useRef("");
|
||||
|
||||
return {
|
||||
displayValue: isEditing.current ? draft.current : sourceDisplay,
|
||||
scrubTo: (value: number) => {
|
||||
const parsed = parse(String(value));
|
||||
if (parsed !== null) onPreview(parsed);
|
||||
},
|
||||
commitScrub: onCommit,
|
||||
onFocus: () => {
|
||||
isEditing.current = true;
|
||||
draft.current = sourceDisplay;
|
||||
forceRender();
|
||||
},
|
||||
onChange: (
|
||||
event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) => {
|
||||
draft.current = event.target.value;
|
||||
forceRender();
|
||||
|
||||
const parsed = parse(event.target.value);
|
||||
if (parsed !== null) {
|
||||
onPreview(parsed);
|
||||
}
|
||||
},
|
||||
onBlur: () => {
|
||||
if (
|
||||
supportsExpressions &&
|
||||
looksLikeExpression({ input: draft.current })
|
||||
) {
|
||||
const evaluated = evaluateMathExpression({ input: draft.current });
|
||||
if (evaluated !== null) {
|
||||
const parsed = parse(String(evaluated));
|
||||
if (parsed !== null) onPreview(parsed);
|
||||
}
|
||||
}
|
||||
onCommit();
|
||||
isEditing.current = false;
|
||||
draft.current = "";
|
||||
forceRender();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { MinusSignIcon, PlusSignIcon } from "@hugeicons/core-free-icons";
|
||||
|
||||
interface PropertyItemProps {
|
||||
direction?: "row" | "column";
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PropertyItem({
|
||||
direction = "row",
|
||||
children,
|
||||
className,
|
||||
}: PropertyItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2",
|
||||
direction === "row"
|
||||
? "items-center justify-between gap-6"
|
||||
: "flex-col gap-1.5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PropertyItemLabel({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("text-muted-foreground text-xs", className)}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function PropertyItemValue({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("flex-1 text-sm", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
interface PropertyGroupProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
collapsible?: boolean;
|
||||
className?: string;
|
||||
hasBorderTop?: boolean;
|
||||
hasBorderBottom?: boolean;
|
||||
}
|
||||
|
||||
export function PropertyGroup({
|
||||
title,
|
||||
children,
|
||||
defaultExpanded = true,
|
||||
collapsible = true,
|
||||
className,
|
||||
hasBorderTop = true,
|
||||
hasBorderBottom = true,
|
||||
}: PropertyGroupProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
hasBorderTop && "border-t",
|
||||
hasBorderBottom && "last:border-b",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{collapsible ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-between p-3.5 cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<PropertyGroupTitle isExpanded={isExpanded}>
|
||||
{title}
|
||||
</PropertyGroupTitle>
|
||||
<HugeiconsIcon
|
||||
icon={isExpanded ? MinusSignIcon : PlusSignIcon}
|
||||
className={cn(
|
||||
"size-3",
|
||||
isExpanded ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<PropertyGroupTitle isExpanded>{title}</PropertyGroupTitle>
|
||||
</div>
|
||||
)}
|
||||
{(collapsible ? isExpanded : true) && (
|
||||
<div className="p-3 pt-0">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertyGroupTitle({
|
||||
children,
|
||||
isExpanded = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isExpanded?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
isExpanded ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import { createContext, useContext, useState } from "react";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ArrowDownIcon } from "@hugeicons/core-free-icons";
|
||||
|
||||
const sectionExpandedCache = new Map<string, boolean>();
|
||||
|
||||
interface SectionContext {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
collapsible: boolean;
|
||||
}
|
||||
|
||||
const SectionCtx = createContext<SectionContext | null>(null);
|
||||
|
||||
function useSectionContext() {
|
||||
return useContext(SectionCtx);
|
||||
}
|
||||
|
||||
interface SectionProps {
|
||||
children: React.ReactNode;
|
||||
collapsible?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
sectionKey?: string;
|
||||
className?: string;
|
||||
hasBorderTop?: boolean;
|
||||
hasBorderBottom?: boolean;
|
||||
}
|
||||
|
||||
export function Section({
|
||||
children,
|
||||
collapsible = false,
|
||||
defaultOpen = true,
|
||||
sectionKey,
|
||||
className,
|
||||
hasBorderTop = true,
|
||||
hasBorderBottom = true,
|
||||
}: SectionProps) {
|
||||
const cached = sectionKey ? sectionExpandedCache.get(sectionKey) : undefined;
|
||||
const [isOpen, setIsOpen] = useState(cached ?? defaultOpen);
|
||||
|
||||
const toggle = () => {
|
||||
const next = !isOpen;
|
||||
setIsOpen(next);
|
||||
if (sectionKey) sectionExpandedCache.set(sectionKey, next);
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCtx.Provider value={{ isOpen, toggle, collapsible }}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
hasBorderTop && "border-t",
|
||||
hasBorderBottom && "last:border-b",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</SectionCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
interface SectionHeaderProps {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SectionHeader({
|
||||
title,
|
||||
children,
|
||||
onClick,
|
||||
className,
|
||||
}: SectionHeaderProps) {
|
||||
const ctx = useSectionContext();
|
||||
const isCollapsible = ctx?.collapsible ?? false;
|
||||
const isOpen = ctx?.isOpen ?? true;
|
||||
const isInteractive = isCollapsible || !!onClick;
|
||||
|
||||
const handleClick = isCollapsible ? ctx?.toggle : onClick;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isOpen ? "text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{children}
|
||||
{isCollapsible && (
|
||||
<HugeiconsIcon
|
||||
icon={ArrowDownIcon}
|
||||
className={cn(
|
||||
"size-3 shrink-0 transition-transform duration-200 ease-out",
|
||||
isOpen
|
||||
? "rotate-0 text-foreground"
|
||||
: "-rotate-90 text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
const baseClassName = cn(
|
||||
"flex w-full items-center justify-between h-11 px-3.5",
|
||||
className,
|
||||
);
|
||||
|
||||
if (isInteractive) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(baseClassName, "cursor-pointer text-left")}
|
||||
onClick={(event) => {
|
||||
handleClick?.();
|
||||
event.currentTarget.blur();
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={baseClassName}>{content}</div>;
|
||||
}
|
||||
|
||||
export function SectionContent({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const ctx = useSectionContext();
|
||||
const isCollapsible = ctx?.collapsible ?? false;
|
||||
const isOpen = ctx?.isOpen ?? true;
|
||||
|
||||
if (isCollapsible) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-[grid-template-rows] duration-100 ease-out",
|
||||
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]",
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className={cn("p-4 pt-0", className)}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={cn("p-4 pt-0", className)}>{children}</div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { usePropertyDraft } from "../hooks/use-property-draft";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import {
|
||||
DEFAULT_BLEND_MODE,
|
||||
DEFAULT_OPACITY,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { OcCheckerboardIcon } from "@opencut/ui/icons";
|
||||
import { Fragment, useRef } from "react";
|
||||
import { Section, SectionContent, SectionHeader } from "../section";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { BlendMode } from "@/types/rendering";
|
||||
import type { ElementType } from "@/types/timeline";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { RainDropIcon } from "@hugeicons/core-free-icons";
|
||||
|
||||
type BlendingElement = {
|
||||
id: string;
|
||||
opacity: number;
|
||||
type: ElementType;
|
||||
blendMode?: BlendMode;
|
||||
};
|
||||
|
||||
const BLEND_MODE_GROUPS = [
|
||||
[{ value: "normal", label: "Normal" }],
|
||||
[
|
||||
{ value: "darken", label: "Darken" },
|
||||
{ value: "multiply", label: "Multiply" },
|
||||
{ value: "color-burn", label: "Color Burn" },
|
||||
],
|
||||
[
|
||||
{ value: "lighten", label: "Lighten" },
|
||||
{ value: "screen", label: "Screen" },
|
||||
{ value: "plus-lighter", label: "Plus Lighter" },
|
||||
{ value: "color-dodge", label: "Color Dodge" },
|
||||
],
|
||||
[
|
||||
{ value: "overlay", label: "Overlay" },
|
||||
{ value: "soft-light", label: "Soft Light" },
|
||||
{ value: "hard-light", label: "Hard Light" },
|
||||
],
|
||||
[
|
||||
{ value: "difference", label: "Difference" },
|
||||
{ value: "exclusion", label: "Exclusion" },
|
||||
],
|
||||
[
|
||||
{ value: "hue", label: "Hue" },
|
||||
{ value: "saturation", label: "Saturation" },
|
||||
{ value: "color", label: "Color" },
|
||||
{ value: "luminosity", label: "Luminosity" },
|
||||
],
|
||||
];
|
||||
|
||||
export function BlendingSection({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: BlendingElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const blendMode = element.blendMode ?? DEFAULT_BLEND_MODE;
|
||||
const didSelectRef = useRef(false);
|
||||
const committedBlendModeRef = useRef(blendMode);
|
||||
if (!editor.timeline.isPreviewActive()) {
|
||||
committedBlendModeRef.current = blendMode;
|
||||
}
|
||||
|
||||
const previewBlendMode = (value: BlendMode) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{ trackId, elementId: element.id, updates: { blendMode: value } },
|
||||
],
|
||||
});
|
||||
|
||||
const commitBlendMode = (value: string) => {
|
||||
if (editor.timeline.isPreviewActive()) {
|
||||
editor.timeline.commitPreview();
|
||||
} else {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { blendMode: value as BlendMode },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
didSelectRef.current = true;
|
||||
};
|
||||
|
||||
const handleBlendModeOpenChange = (isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
if (!didSelectRef.current) editor.timeline.discardPreview();
|
||||
didSelectRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const opacity = usePropertyDraft({
|
||||
displayValue: Math.round(element.opacity * 100).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return clamp({ value: parsed, min: 0, max: 100 }) / 100;
|
||||
},
|
||||
onPreview: (value) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{ trackId, elementId: element.id, updates: { opacity: value } },
|
||||
],
|
||||
}),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey={`${element.type}:blending`}>
|
||||
<SectionHeader title="Blending" />
|
||||
<SectionContent>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-1/2 space-y-1.5">
|
||||
<NumberField
|
||||
className="w-full"
|
||||
icon={
|
||||
<OcCheckerboardIcon className="size-3.5 text-muted-foreground" />
|
||||
}
|
||||
value={opacity.displayValue}
|
||||
min={0}
|
||||
max={100}
|
||||
onFocus={opacity.onFocus}
|
||||
onChange={opacity.onChange}
|
||||
onBlur={opacity.onBlur}
|
||||
onScrub={opacity.scrubTo}
|
||||
onScrubEnd={opacity.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: DEFAULT_OPACITY },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={element.opacity === DEFAULT_OPACITY}
|
||||
dragSensitivity="slow"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1/2 space-y-1.5">
|
||||
<Select
|
||||
value={committedBlendModeRef.current}
|
||||
onOpenChange={handleBlendModeOpenChange}
|
||||
onValueChange={commitBlendMode}
|
||||
>
|
||||
<SelectTrigger
|
||||
icon={<HugeiconsIcon icon={RainDropIcon} />}
|
||||
className="w-full"
|
||||
>
|
||||
<SelectValue placeholder="Select blend mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="w-36">
|
||||
{BLEND_MODE_GROUPS.map((group, groupIndex) => (
|
||||
<Fragment key={group[0]?.value ?? `group-${groupIndex}`}>
|
||||
{group.map((option) => (
|
||||
<SelectItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onPointerEnter={() =>
|
||||
previewBlendMode(option.value as BlendMode)
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{groupIndex < BLEND_MODE_GROUPS.length - 1 ? (
|
||||
<SelectSeparator />
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export * from "./transform";
|
||||
export * from "./blending";
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { usePropertyDraft } from "../hooks/use-property-draft";
|
||||
import { clamp } from "@/utils/math";
|
||||
import type { ElementType, Transform } from "@/types/timeline";
|
||||
import { Section, SectionContent, SectionHeader } from "../section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
ArrowExpandIcon,
|
||||
Link05Icon,
|
||||
RotateClockwiseIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { useState } from "react";
|
||||
import { DEFAULT_TRANSFORM } from "@/constants/timeline-constants";
|
||||
|
||||
type TransformElement = {
|
||||
id: string;
|
||||
transform: Transform;
|
||||
type: ElementType;
|
||||
};
|
||||
|
||||
function parseFloat_({ input }: { input: string }): number | null {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
export function TransformSection({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: TransformElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const [isScaleLocked, setIsScaleLocked] = useState(false);
|
||||
|
||||
const previewTransform = (transform: Partial<Transform>) => {
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { transform: { ...element.transform, ...transform } },
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const commit = () => editor.timeline.commitPreview();
|
||||
|
||||
const positionX = usePropertyDraft({
|
||||
displayValue: Math.round(element.transform.position.x).toString(),
|
||||
parse: (input) => parseFloat_({ input }),
|
||||
onPreview: (value) =>
|
||||
previewTransform({
|
||||
position: { ...element.transform.position, x: value },
|
||||
}),
|
||||
onCommit: commit,
|
||||
});
|
||||
|
||||
const positionY = usePropertyDraft({
|
||||
displayValue: Math.round(element.transform.position.y).toString(),
|
||||
parse: (input) => parseFloat_({ input }),
|
||||
onPreview: (value) =>
|
||||
previewTransform({
|
||||
position: { ...element.transform.position, y: value },
|
||||
}),
|
||||
onCommit: commit,
|
||||
});
|
||||
|
||||
const scale = usePropertyDraft({
|
||||
displayValue: Math.round(element.transform.scale * 100).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat_({ input });
|
||||
if (parsed === null) return null;
|
||||
return Math.max(parsed, 1) / 100;
|
||||
},
|
||||
onPreview: (value) => previewTransform({ scale: value }),
|
||||
onCommit: commit,
|
||||
});
|
||||
const scaleFieldProps = {
|
||||
className: "flex-1",
|
||||
value: scale.displayValue,
|
||||
onFocus: scale.onFocus,
|
||||
onChange: scale.onChange,
|
||||
onBlur: scale.onBlur,
|
||||
dragSensitivity: "slow" as const,
|
||||
onScrub: scale.scrubTo,
|
||||
onScrubEnd: scale.commitScrub,
|
||||
onReset: () =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
transform: {
|
||||
...element.transform,
|
||||
scale: DEFAULT_TRANSFORM.scale,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
isDefault: element.transform.scale === DEFAULT_TRANSFORM.scale,
|
||||
};
|
||||
|
||||
const rotation = usePropertyDraft({
|
||||
displayValue: Math.round(element.transform.rotate).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat_({ input });
|
||||
if (parsed === null) return null;
|
||||
return clamp({ value: parsed, min: -360, max: 360 });
|
||||
},
|
||||
onPreview: (value) => previewTransform({ rotate: value }),
|
||||
onCommit: commit,
|
||||
});
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey={`${element.type}:transform`}>
|
||||
<SectionHeader title="Transform" />
|
||||
<SectionContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{isScaleLocked ? (
|
||||
<>
|
||||
<NumberField icon="W" {...scaleFieldProps} />
|
||||
<NumberField icon="H" {...scaleFieldProps} />
|
||||
</>
|
||||
) : (
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
|
||||
{...scaleFieldProps}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
variant={isScaleLocked ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
aria-pressed={isScaleLocked}
|
||||
onClick={() => setIsScaleLocked((isLocked) => !isLocked)}
|
||||
>
|
||||
<HugeiconsIcon icon={Link05Icon} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberField
|
||||
icon="X"
|
||||
className="flex-1"
|
||||
value={positionX.displayValue}
|
||||
onFocus={positionX.onFocus}
|
||||
onChange={positionX.onChange}
|
||||
onBlur={positionX.onBlur}
|
||||
onScrub={positionX.scrubTo}
|
||||
onScrubEnd={positionX.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: {
|
||||
...element.transform.position,
|
||||
x: DEFAULT_TRANSFORM.position.x,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={
|
||||
element.transform.position.x === DEFAULT_TRANSFORM.position.x
|
||||
}
|
||||
/>
|
||||
<NumberField
|
||||
icon="Y"
|
||||
className="flex-1"
|
||||
value={positionY.displayValue}
|
||||
onFocus={positionY.onFocus}
|
||||
onChange={positionY.onChange}
|
||||
onBlur={positionY.onBlur}
|
||||
onScrub={positionY.scrubTo}
|
||||
onScrubEnd={positionY.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: {
|
||||
...element.transform.position,
|
||||
y: DEFAULT_TRANSFORM.position.y,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={
|
||||
element.transform.position.y === DEFAULT_TRANSFORM.position.y
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
|
||||
className="flex-1"
|
||||
value={rotation.displayValue}
|
||||
onFocus={rotation.onFocus}
|
||||
onChange={rotation.onChange}
|
||||
onBlur={rotation.onBlur}
|
||||
dragSensitivity="slow"
|
||||
onScrub={rotation.scrubTo}
|
||||
onScrubEnd={rotation.commitScrub}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
transform: {
|
||||
...element.transform,
|
||||
rotate: DEFAULT_TRANSFORM.rotate,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={element.transform.rotate === DEFAULT_TRANSFORM.rotate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,24 +1,31 @@
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { FontPicker } from "@/components/ui/font-picker";
|
||||
import type { FontFamily } from "@/constants/font-constants";
|
||||
import type { TextElement } from "@/types/timeline";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useReducer, useRef } from "react";
|
||||
import { PanelBaseView } from "@/components/editor/panels/panel-base-view";
|
||||
import {
|
||||
PropertyGroup,
|
||||
PropertyItem,
|
||||
PropertyItemLabel,
|
||||
PropertyItemValue,
|
||||
} from "./property-item";
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { useRef } from "react";
|
||||
import { Section, SectionContent, SectionHeader } from "./section";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { uppercase } from "@/utils/string";
|
||||
import { clamp } from "@/utils/math";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { DEFAULT_COLOR } from "@/constants/project-constants";
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "@/constants/text-constants";
|
||||
import {
|
||||
DEFAULT_TEXT_ELEMENT,
|
||||
MAX_FONT_SIZE,
|
||||
MIN_FONT_SIZE,
|
||||
} from "@/constants/text-constants";
|
||||
import { usePropertyDraft } from "./hooks/use-property-draft";
|
||||
import { TransformSection, BlendingSection } from "./sections";
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@/components/ui/select";
|
||||
import { OcFontWeightIcon } from "@opencut/ui/icons";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export function TextProperties({
|
||||
element,
|
||||
|
|
@ -26,625 +33,230 @@ export function TextProperties({
|
|||
}: {
|
||||
element: TextElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<ContentSection element={element} trackId={trackId} />
|
||||
<TransformSection element={element} trackId={trackId} />
|
||||
<BlendingSection element={element} trackId={trackId} />
|
||||
<FontSection element={element} trackId={trackId} />
|
||||
<ColorSection element={element} trackId={trackId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentSection({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: TextElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [, forceRender] = useReducer((x: number) => x + 1, 0);
|
||||
const isEditingFontSize = useRef(false);
|
||||
const isEditingOpacity = useRef(false);
|
||||
const isEditingContent = useRef(false);
|
||||
const fontSizeDraft = useRef("");
|
||||
const opacityDraft = useRef("");
|
||||
const contentDraft = useRef("");
|
||||
|
||||
const fontSizeDisplay = isEditingFontSize.current
|
||||
? fontSizeDraft.current
|
||||
: element.fontSize.toString();
|
||||
const opacityDisplay = isEditingOpacity.current
|
||||
? opacityDraft.current
|
||||
: Math.round(element.opacity * 100).toString();
|
||||
const contentDisplay = isEditingContent.current
|
||||
? contentDraft.current
|
||||
: element.content;
|
||||
|
||||
const lastSelectedColor = useRef(DEFAULT_COLOR);
|
||||
const initialFontSizeRef = useRef<number | null>(null);
|
||||
const initialOpacityRef = useRef<number | null>(null);
|
||||
const initialContentRef = useRef<string | null>(null);
|
||||
const initialColorRef = useRef<string | null>(null);
|
||||
const initialBgColorRef = useRef<string | null>(null);
|
||||
|
||||
const handleFontSizeChange = ({ value }: { value: string }) => {
|
||||
fontSizeDraft.current = value;
|
||||
forceRender();
|
||||
|
||||
if (value.trim() !== "") {
|
||||
if (initialFontSizeRef.current === null) {
|
||||
initialFontSizeRef.current = element.fontSize;
|
||||
}
|
||||
const parsed = parseInt(value, 10);
|
||||
const fontSize = Number.isNaN(parsed)
|
||||
? element.fontSize
|
||||
: clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
|
||||
editor.timeline.updateElements({
|
||||
updates: [{ trackId, elementId: element.id, updates: { fontSize } }],
|
||||
pushHistory: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFontSizeBlur = () => {
|
||||
if (initialFontSizeRef.current !== null) {
|
||||
const parsed = parseInt(fontSizeDraft.current, 10);
|
||||
const fontSize = Number.isNaN(parsed)
|
||||
? element.fontSize
|
||||
: clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
|
||||
editor.timeline.updateElements({
|
||||
const content = usePropertyDraft({
|
||||
displayValue: element.content,
|
||||
parse: (input) => input,
|
||||
onPreview: (value) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize: initialFontSizeRef.current },
|
||||
},
|
||||
{ trackId, elementId: element.id, updates: { content: value } },
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
editor.timeline.updateElements({
|
||||
updates: [{ trackId, elementId: element.id, updates: { fontSize } }],
|
||||
pushHistory: true,
|
||||
});
|
||||
initialFontSizeRef.current = null;
|
||||
}
|
||||
isEditingFontSize.current = false;
|
||||
fontSizeDraft.current = "";
|
||||
forceRender();
|
||||
};
|
||||
|
||||
const handleOpacityChange = ({ value }: { value: string }) => {
|
||||
opacityDraft.current = value;
|
||||
forceRender();
|
||||
|
||||
if (value.trim() !== "") {
|
||||
if (initialOpacityRef.current === null) {
|
||||
initialOpacityRef.current = element.opacity;
|
||||
}
|
||||
const parsed = parseInt(value, 10);
|
||||
const opacityPercent = Number.isNaN(parsed)
|
||||
? Math.round(element.opacity * 100)
|
||||
: clamp({ value: parsed, min: 0, max: 100 });
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: opacityPercent / 100 },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpacityBlur = () => {
|
||||
if (initialOpacityRef.current !== null) {
|
||||
const parsed = parseInt(opacityDraft.current, 10);
|
||||
const opacityPercent = Number.isNaN(parsed)
|
||||
? Math.round(element.opacity * 100)
|
||||
: clamp({ value: parsed, min: 0, max: 100 });
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: initialOpacityRef.current },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: opacityPercent / 100 },
|
||||
},
|
||||
],
|
||||
pushHistory: true,
|
||||
});
|
||||
initialOpacityRef.current = null;
|
||||
}
|
||||
isEditingOpacity.current = false;
|
||||
opacityDraft.current = "";
|
||||
forceRender();
|
||||
};
|
||||
|
||||
const handleColorChange = ({ color }: { color: string }) => {
|
||||
if (color !== "transparent") {
|
||||
lastSelectedColor.current = color;
|
||||
}
|
||||
if (initialBgColorRef.current === null) {
|
||||
initialBgColorRef.current = element.backgroundColor;
|
||||
}
|
||||
if (initialBgColorRef.current !== null) {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { backgroundColor: color },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
} else {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { backgroundColor: color },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleColorChangeEnd = ({ color }: { color: string }) => {
|
||||
if (initialBgColorRef.current !== null) {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { backgroundColor: initialBgColorRef.current },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { backgroundColor: `#${color}` },
|
||||
},
|
||||
],
|
||||
pushHistory: true,
|
||||
});
|
||||
initialBgColorRef.current = null;
|
||||
}
|
||||
};
|
||||
}),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col" ref={containerRef}>
|
||||
<PanelBaseView className="p-0">
|
||||
<PropertyGroup title="Content" hasBorderTop={false} collapsible={false}>
|
||||
<Textarea
|
||||
placeholder="Name"
|
||||
value={contentDisplay}
|
||||
className="bg-accent min-h-20"
|
||||
onFocus={() => {
|
||||
isEditingContent.current = true;
|
||||
contentDraft.current = element.content;
|
||||
initialContentRef.current = element.content;
|
||||
forceRender();
|
||||
}}
|
||||
onChange={(event) => {
|
||||
contentDraft.current = event.target.value;
|
||||
forceRender();
|
||||
if (initialContentRef.current === null) {
|
||||
initialContentRef.current = element.content;
|
||||
}
|
||||
<Section collapsible sectionKey="text:content" hasBorderTop={false}>
|
||||
<SectionHeader title="Content" />
|
||||
<SectionContent>
|
||||
<Textarea
|
||||
placeholder="Name"
|
||||
value={content.displayValue}
|
||||
className="min-h-20"
|
||||
onFocus={content.onFocus}
|
||||
onChange={content.onChange}
|
||||
onBlur={content.onBlur}
|
||||
/>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function FontSection({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: TextElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
|
||||
const fontSize = usePropertyDraft({
|
||||
displayValue: element.fontSize.toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseFloat(input);
|
||||
if (Number.isNaN(parsed)) return null;
|
||||
return clamp({ value: parsed, min: MIN_FONT_SIZE, max: MAX_FONT_SIZE });
|
||||
},
|
||||
onPreview: (value) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{ trackId, elementId: element.id, updates: { fontSize: value } },
|
||||
],
|
||||
}),
|
||||
onCommit: () => editor.timeline.commitPreview(),
|
||||
});
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey="text:font">
|
||||
<SectionHeader title="Font" />
|
||||
<SectionContent>
|
||||
<div className="flex flex-col gap-2">
|
||||
<FontPicker
|
||||
defaultValue={element.fontFamily}
|
||||
onValueChange={(value) =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { content: event.target.value },
|
||||
updates: { fontFamily: value },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (initialContentRef.current !== null) {
|
||||
const finalContent = contentDraft.current;
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { content: initialContentRef.current },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { content: finalContent },
|
||||
},
|
||||
],
|
||||
pushHistory: true,
|
||||
});
|
||||
initialContentRef.current = null;
|
||||
}
|
||||
isEditingContent.current = false;
|
||||
contentDraft.current = "";
|
||||
forceRender();
|
||||
}}
|
||||
})
|
||||
}
|
||||
/>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup title="Typography" collapsible={false}>
|
||||
<div className="space-y-6">
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Font</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<FontPicker
|
||||
defaultValue={element.fontFamily}
|
||||
onValueChange={(value: FontFamily) =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontFamily: value },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Style</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant={
|
||||
element.fontWeight === "bold" ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
fontWeight:
|
||||
element.fontWeight === "bold"
|
||||
? "normal"
|
||||
: "bold",
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 font-bold"
|
||||
>
|
||||
B
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
element.fontStyle === "italic" ? "default" : "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
fontStyle:
|
||||
element.fontStyle === "italic"
|
||||
? "normal"
|
||||
: "italic",
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 italic"
|
||||
>
|
||||
I
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
element.textDecoration === "underline"
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
textDecoration:
|
||||
element.textDecoration === "underline"
|
||||
? "none"
|
||||
: "underline",
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 underline"
|
||||
>
|
||||
U
|
||||
</Button>
|
||||
<Button
|
||||
variant={
|
||||
element.textDecoration === "line-through"
|
||||
? "default"
|
||||
: "outline"
|
||||
}
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
textDecoration:
|
||||
element.textDecoration === "line-through"
|
||||
? "none"
|
||||
: "line-through",
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
className="h-8 px-3 line-through"
|
||||
>
|
||||
S
|
||||
</Button>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Font size</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
value={[element.fontSize]}
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
step={1}
|
||||
onValueChange={([value]) => {
|
||||
if (initialFontSizeRef.current === null) {
|
||||
initialFontSizeRef.current = element.fontSize;
|
||||
}
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize: value },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
}}
|
||||
onValueCommit={([value]) => {
|
||||
if (initialFontSizeRef.current !== null) {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
fontSize: initialFontSizeRef.current,
|
||||
},
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize: value },
|
||||
},
|
||||
],
|
||||
pushHistory: true,
|
||||
});
|
||||
initialFontSizeRef.current = null;
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={fontSizeDisplay}
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
onFocus={() => {
|
||||
isEditingFontSize.current = true;
|
||||
fontSizeDraft.current = element.fontSize.toString();
|
||||
forceRender();
|
||||
}}
|
||||
onChange={(e) =>
|
||||
handleFontSizeChange({ value: e.target.value })
|
||||
}
|
||||
onBlur={handleFontSizeBlur}
|
||||
className="bg-accent h-7 w-12 [appearance:textfield] rounded-sm px-2 text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<Select value={element.fontWeight}>
|
||||
<SelectTrigger className="w-full" icon={<OcFontWeightIcon />}>
|
||||
<SelectValue placeholder="Select weight" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="100">Thin</SelectItem>
|
||||
<SelectItem value="200">Extra Light</SelectItem>
|
||||
<SelectItem value="300">Light</SelectItem>
|
||||
<SelectItem value="400">Normal</SelectItem>
|
||||
<SelectItem value="500">Medium</SelectItem>
|
||||
<SelectItem value="600">Semi Bold</SelectItem>
|
||||
<SelectItem value="700">Bold</SelectItem>
|
||||
<SelectItem value="800">Extra Bold</SelectItem>
|
||||
<SelectItem value="900">Black</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Label>Font size</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
value={[element.fontSize]}
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
step={1}
|
||||
onValueChange={([value]) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { fontSize: value },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
onValueCommit={() => editor.timeline.commitPreview()}
|
||||
className="w-full"
|
||||
/>
|
||||
<NumberField
|
||||
className="w-18 shrink-0"
|
||||
value={fontSize.displayValue}
|
||||
min={MIN_FONT_SIZE}
|
||||
max={MAX_FONT_SIZE}
|
||||
onFocus={fontSize.onFocus}
|
||||
onChange={fontSize.onChange}
|
||||
onBlur={fontSize.onBlur}
|
||||
onReset={() =>
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: {
|
||||
fontSize: DEFAULT_TEXT_ELEMENT.fontSize,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
isDefault={element.fontSize === DEFAULT_TEXT_ELEMENT.fontSize}
|
||||
/>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup title="Appearance" collapsible={false}>
|
||||
<div className="space-y-6">
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Color</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<ColorPicker
|
||||
value={uppercase({
|
||||
string: (element.color || "FFFFFF").replace("#", ""),
|
||||
})}
|
||||
onChange={(color) => {
|
||||
if (initialColorRef.current === null) {
|
||||
initialColorRef.current = element.color || "#FFFFFF";
|
||||
}
|
||||
if (initialColorRef.current !== null) {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { color: `#${color}` },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
} else {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { color: `#${color}` },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}}
|
||||
onChangeEnd={(color) => {
|
||||
if (initialColorRef.current !== null) {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { color: initialColorRef.current },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { color: `#${color}` },
|
||||
},
|
||||
],
|
||||
pushHistory: true,
|
||||
});
|
||||
initialColorRef.current = null;
|
||||
}
|
||||
}}
|
||||
containerRef={containerRef}
|
||||
/>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Opacity</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<div className="flex items-center gap-2">
|
||||
<Slider
|
||||
value={[element.opacity * 100]}
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
onValueChange={([value]) => {
|
||||
if (initialOpacityRef.current === null) {
|
||||
initialOpacityRef.current = element.opacity;
|
||||
}
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: value / 100 },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
}}
|
||||
onValueCommit={([value]) => {
|
||||
if (initialOpacityRef.current !== null) {
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: initialOpacityRef.current },
|
||||
},
|
||||
],
|
||||
pushHistory: false,
|
||||
});
|
||||
editor.timeline.updateElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { opacity: value / 100 },
|
||||
},
|
||||
],
|
||||
pushHistory: true,
|
||||
});
|
||||
initialOpacityRef.current = null;
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
value={opacityDisplay}
|
||||
min={0}
|
||||
max={100}
|
||||
onFocus={() => {
|
||||
isEditingOpacity.current = true;
|
||||
opacityDraft.current = Math.round(
|
||||
element.opacity * 100,
|
||||
).toString();
|
||||
forceRender();
|
||||
}}
|
||||
onChange={(e) =>
|
||||
handleOpacityChange({ value: e.target.value })
|
||||
}
|
||||
onBlur={handleOpacityBlur}
|
||||
className="bg-accent h-7 w-12 [appearance:textfield] rounded-sm text-center !text-xs [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
<PropertyItem direction="column">
|
||||
<PropertyItemLabel>Background</PropertyItemLabel>
|
||||
<PropertyItemValue>
|
||||
<ColorPicker
|
||||
value={
|
||||
element.backgroundColor === "transparent"
|
||||
? lastSelectedColor.current.replace("#", "")
|
||||
: element.backgroundColor.replace("#", "")
|
||||
}
|
||||
onChange={(color) =>
|
||||
handleColorChange({ color: `#${color}` })
|
||||
}
|
||||
onChangeEnd={(color) => handleColorChangeEnd({ color })}
|
||||
containerRef={containerRef}
|
||||
className={
|
||||
element.backgroundColor === "transparent"
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</PropertyItemValue>
|
||||
</PropertyItem>
|
||||
</div>
|
||||
</PropertyGroup>
|
||||
</PanelBaseView>
|
||||
</div>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorSection({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: TextElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const lastSelectedColor = useRef(DEFAULT_COLOR);
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey="text:color" hasBorderBottom={false}>
|
||||
<SectionHeader title="Color" />
|
||||
<SectionContent>
|
||||
<div className="flex flex-col gap-6">
|
||||
<ColorPicker
|
||||
value={uppercase({
|
||||
string: (element.color || "FFFFFF").replace("#", ""),
|
||||
})}
|
||||
onChange={(color) =>
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { color: `#${color}` },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
onChangeEnd={() => editor.timeline.commitPreview()}
|
||||
/>
|
||||
<ColorPicker
|
||||
value={
|
||||
element.backgroundColor === "transparent"
|
||||
? lastSelectedColor.current.replace("#", "")
|
||||
: element.backgroundColor.replace("#", "")
|
||||
}
|
||||
onChange={(color) => {
|
||||
const hexColor = `#${color}`;
|
||||
if (color !== "transparent") {
|
||||
lastSelectedColor.current = hexColor;
|
||||
}
|
||||
editor.timeline.previewElements({
|
||||
updates: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
updates: { backgroundColor: hexColor },
|
||||
},
|
||||
],
|
||||
});
|
||||
}}
|
||||
onChangeEnd={() => editor.timeline.commitPreview()}
|
||||
className={
|
||||
element.backgroundColor === "transparent"
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,75 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { EditorCore } from "@/core";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import type { BookmarkDragState } from "@/hooks/timeline/use-bookmark-drag";
|
||||
import { BOOKMARK_TIME_EPSILON } from "@/lib/timeline/bookmarks";
|
||||
import {
|
||||
DEFAULT_BOOKMARK_COLOR,
|
||||
TIMELINE_CONSTANTS,
|
||||
} from "@/constants/timeline-constants";
|
||||
import { DEFAULT_FPS } from "@/constants/project-constants";
|
||||
import { getSnappedSeekTime } from "@/lib/time";
|
||||
import { Bookmark02Icon } from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
ArrowTurnBackwardIcon,
|
||||
Delete02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { Bookmark } from "@/types/timeline";
|
||||
import {
|
||||
Popover,
|
||||
PopoverAnchor,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ColorPicker } from "@/components/ui/color-picker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { uppercase } from "@/utils/string";
|
||||
import { clamp } from "@/utils/math";
|
||||
|
||||
const PIXELS_PER_SECOND = TIMELINE_CONSTANTS.PIXELS_PER_SECOND;
|
||||
const MIN_BOOKMARK_WIDTH_PX = 2;
|
||||
const BOOKMARK_MARKER_WIDTH_PX = 12;
|
||||
const BOOKMARK_MARKER_HEIGHT_PX = 15;
|
||||
const BOOKMARK_HALF_WIDTH_PX = BOOKMARK_MARKER_WIDTH_PX / 2;
|
||||
const BOOKMARK_MARKER_CLIP_PATH =
|
||||
"polygon(50% 100%, 12% 72%, 12% 10%, 88% 10%, 88% 72%)";
|
||||
|
||||
function seekToBookmarkTime({
|
||||
editor,
|
||||
time,
|
||||
}: {
|
||||
editor: EditorCore;
|
||||
time: number;
|
||||
}) {
|
||||
const activeProject = editor.project.getActive();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const fps = activeProject?.settings.fps ?? DEFAULT_FPS;
|
||||
const snappedTime = getSnappedSeekTime({ rawTime: time, duration, fps });
|
||||
editor.playback.seek({ time: snappedTime });
|
||||
}
|
||||
|
||||
interface TimelineBookmarksRowProps {
|
||||
zoomLevel: number;
|
||||
dynamicTimelineWidth: number;
|
||||
handleWheel: (e: React.WheelEvent) => void;
|
||||
handleTimelineContentClick: (e: React.MouseEvent) => void;
|
||||
handleRulerTrackingMouseDown: (e: React.MouseEvent) => void;
|
||||
handleRulerMouseDown: (e: React.MouseEvent) => void;
|
||||
dragState: BookmarkDragState;
|
||||
onBookmarkMouseDown: (params: {
|
||||
event: React.MouseEvent;
|
||||
bookmark: Bookmark;
|
||||
}) => void;
|
||||
handleWheel: (event: React.WheelEvent) => void;
|
||||
handleTimelineContentClick: (event: React.MouseEvent) => void;
|
||||
handleRulerTrackingMouseDown: (event: React.MouseEvent) => void;
|
||||
handleRulerMouseDown: (event: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function TimelineBookmarksRow({
|
||||
zoomLevel,
|
||||
dynamicTimelineWidth,
|
||||
dragState,
|
||||
onBookmarkMouseDown,
|
||||
handleWheel,
|
||||
handleTimelineContentClick,
|
||||
handleRulerTrackingMouseDown,
|
||||
|
|
@ -34,17 +88,23 @@ export function TimelineBookmarksRow({
|
|||
aria-label="Timeline ruler"
|
||||
type="button"
|
||||
onWheel={handleWheel}
|
||||
onClick={handleTimelineContentClick}
|
||||
onClick={(event) => {
|
||||
if (!event.currentTarget.contains(event.target as Node)) return;
|
||||
handleTimelineContentClick(event);
|
||||
}}
|
||||
onMouseDown={(event) => {
|
||||
if (!event.currentTarget.contains(event.target as Node)) return;
|
||||
handleRulerMouseDown(event);
|
||||
handleRulerTrackingMouseDown(event);
|
||||
}}
|
||||
>
|
||||
{activeScene.bookmarks.map((time: number) => (
|
||||
{activeScene.bookmarks.map((bookmark) => (
|
||||
<TimelineBookmark
|
||||
key={`bookmark-row-${time}`}
|
||||
time={time}
|
||||
key={`bookmark-${bookmark.time}`}
|
||||
bookmark={bookmark}
|
||||
zoomLevel={zoomLevel}
|
||||
dragState={dragState}
|
||||
onBookmarkMouseDown={onBookmarkMouseDown}
|
||||
/>
|
||||
))}
|
||||
</button>
|
||||
|
|
@ -52,60 +112,305 @@ export function TimelineBookmarksRow({
|
|||
);
|
||||
}
|
||||
|
||||
export function TimelineBookmark({
|
||||
time,
|
||||
function TimelineBookmark({
|
||||
bookmark,
|
||||
zoomLevel,
|
||||
dragState,
|
||||
onBookmarkMouseDown,
|
||||
}: {
|
||||
time: number;
|
||||
bookmark: Bookmark;
|
||||
zoomLevel: number;
|
||||
dragState: BookmarkDragState;
|
||||
onBookmarkMouseDown: (params: {
|
||||
event: React.MouseEvent;
|
||||
bookmark: Bookmark;
|
||||
}) => void;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const activeProject = editor.project.getActive();
|
||||
const duration = editor.timeline.getTotalDuration();
|
||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
||||
|
||||
const handleBookmarkActivate = ({
|
||||
event,
|
||||
}: {
|
||||
event:
|
||||
| React.MouseEvent<HTMLButtonElement>
|
||||
| React.KeyboardEvent<HTMLButtonElement>;
|
||||
}) => {
|
||||
const isDragging =
|
||||
dragState.isDragging &&
|
||||
dragState.bookmarkTime !== null &&
|
||||
Math.abs(dragState.bookmarkTime - bookmark.time) < BOOKMARK_TIME_EPSILON;
|
||||
|
||||
const displayTime = isDragging ? dragState.currentTime : bookmark.time;
|
||||
const time = bookmark.time;
|
||||
const bookmarkDuration = bookmark.duration ?? 0;
|
||||
const durationWidth =
|
||||
bookmarkDuration > 0 ? bookmarkDuration * PIXELS_PER_SECOND * zoomLevel : 0;
|
||||
const hasDurationRange = durationWidth > MIN_BOOKMARK_WIDTH_PX;
|
||||
const bookmarkWidth = BOOKMARK_MARKER_WIDTH_PX + Math.max(durationWidth, 0);
|
||||
const left = displayTime * PIXELS_PER_SECOND * zoomLevel;
|
||||
const bookmarkLeft = left - BOOKMARK_HALF_WIDTH_PX;
|
||||
const rightHalfLeft = BOOKMARK_HALF_WIDTH_PX + Math.max(durationWidth, 0);
|
||||
const iconColor = bookmark.color ?? DEFAULT_BOOKMARK_COLOR;
|
||||
|
||||
const handleSeek = () => seekToBookmarkTime({ editor, time });
|
||||
|
||||
const handleClick = (event: React.MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.detail === 2) {
|
||||
setIsPopoverOpen(true);
|
||||
} else {
|
||||
handleSeek();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
handleSeek();
|
||||
};
|
||||
|
||||
const handleMouseDown = (event: React.MouseEvent) => {
|
||||
onBookmarkMouseDown({ event, bookmark });
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const framesPerSecond = activeProject?.settings.fps ?? 30;
|
||||
const snappedTime = getSnappedSeekTime({
|
||||
rawTime: time,
|
||||
duration,
|
||||
fps: framesPerSecond,
|
||||
});
|
||||
editor.playback.seek({ time: snappedTime });
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className="absolute top-0 h-10 w-0.5 cursor-pointer border-0 bg-transparent p-0"
|
||||
style={{
|
||||
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
|
||||
}}
|
||||
aria-label={`Seek to bookmark at ${time}s`}
|
||||
type="button"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
onClick={(event) => handleBookmarkActivate({ event })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return;
|
||||
event.preventDefault();
|
||||
handleBookmarkActivate({ event });
|
||||
}}
|
||||
>
|
||||
<div className="text-primary absolute top-[-1px] left-[-5px]">
|
||||
<HugeiconsIcon
|
||||
icon={Bookmark02Icon}
|
||||
aria-hidden="true"
|
||||
className="fill-primary size-3"
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<PopoverAnchor asChild>
|
||||
<button
|
||||
className={`absolute top-0 h-full min-w-0.5 border-0 bg-transparent p-0 ${isDragging ? "cursor-grabbing" : "cursor-grab"}`}
|
||||
style={{
|
||||
left: `${bookmarkLeft}px`,
|
||||
width: `${bookmarkWidth}px`,
|
||||
}}
|
||||
aria-label={`Bookmark at ${time.toFixed(1)}s`}
|
||||
type="button"
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{hasDurationRange ? (
|
||||
<div
|
||||
className="absolute opacity-30"
|
||||
style={{
|
||||
top: 1.5,
|
||||
height: BOOKMARK_MARKER_HEIGHT_PX - 2.5,
|
||||
left: BOOKMARK_HALF_WIDTH_PX,
|
||||
width: durationWidth,
|
||||
backgroundColor: iconColor,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className="absolute top-0 overflow-hidden"
|
||||
style={{
|
||||
left: 0,
|
||||
width: BOOKMARK_HALF_WIDTH_PX,
|
||||
height: BOOKMARK_MARKER_HEIGHT_PX,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
width: BOOKMARK_MARKER_WIDTH_PX,
|
||||
height: BOOKMARK_MARKER_HEIGHT_PX,
|
||||
clipPath: BOOKMARK_MARKER_CLIP_PATH,
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
top: 1,
|
||||
left: 1,
|
||||
width: BOOKMARK_MARKER_WIDTH_PX - 2,
|
||||
height: BOOKMARK_MARKER_HEIGHT_PX - 2,
|
||||
clipPath: BOOKMARK_MARKER_CLIP_PATH,
|
||||
backgroundColor: iconColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="absolute top-0 overflow-hidden"
|
||||
style={{
|
||||
left: rightHalfLeft,
|
||||
width: BOOKMARK_HALF_WIDTH_PX,
|
||||
height: BOOKMARK_MARKER_HEIGHT_PX,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0"
|
||||
style={{
|
||||
left: -BOOKMARK_HALF_WIDTH_PX,
|
||||
width: BOOKMARK_MARKER_WIDTH_PX,
|
||||
height: BOOKMARK_MARKER_HEIGHT_PX,
|
||||
clipPath: BOOKMARK_MARKER_CLIP_PATH,
|
||||
backgroundColor: "hsl(var(--background))",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
top: 1,
|
||||
left: 1 - BOOKMARK_HALF_WIDTH_PX,
|
||||
width: BOOKMARK_MARKER_WIDTH_PX - 2,
|
||||
height: BOOKMARK_MARKER_HEIGHT_PX - 2,
|
||||
clipPath: BOOKMARK_MARKER_CLIP_PATH,
|
||||
backgroundColor: iconColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
className="w-64 flex flex-col gap-3 p-3"
|
||||
align="start"
|
||||
side="bottom"
|
||||
sideOffset={8}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<BookmarkPopoverContent
|
||||
bookmark={bookmark}
|
||||
time={time}
|
||||
timelineDuration={duration}
|
||||
onPopoverClose={() => setIsPopoverOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function BookmarkPopoverContent({
|
||||
bookmark,
|
||||
time,
|
||||
timelineDuration,
|
||||
onPopoverClose,
|
||||
}: {
|
||||
bookmark: Bookmark;
|
||||
time: number;
|
||||
timelineDuration: number;
|
||||
onPopoverClose: () => void;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const [draftColorHex, setDraftColorHex] = useState(
|
||||
(bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftColorHex(
|
||||
(bookmark.color ?? DEFAULT_BOOKMARK_COLOR).replace("#", "").toUpperCase(),
|
||||
);
|
||||
}, [bookmark.color]);
|
||||
|
||||
const handleRemove = () => {
|
||||
editor.scenes.removeBookmark({ time });
|
||||
onPopoverClose();
|
||||
};
|
||||
|
||||
const handleUpdate = ({
|
||||
note,
|
||||
color,
|
||||
duration,
|
||||
}: Partial<{ note: string; color: string; duration: number }>) => {
|
||||
const updates: Partial<{ note: string; color: string; duration: number }> =
|
||||
{};
|
||||
if (note !== undefined && note !== bookmark.note) updates.note = note;
|
||||
if (
|
||||
color !== undefined &&
|
||||
color.toUpperCase() !== (bookmark.color ?? "").toUpperCase()
|
||||
) {
|
||||
updates.color = color;
|
||||
}
|
||||
if (duration !== undefined && duration !== bookmark.duration) {
|
||||
updates.duration = duration;
|
||||
}
|
||||
if (Object.keys(updates).length === 0) return;
|
||||
editor.scenes.updateBookmark({ time, updates });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs">Note</Label>
|
||||
<Input
|
||||
placeholder="Add a note..."
|
||||
value={bookmark.note ?? ""}
|
||||
onChange={(event) => handleUpdate({ note: event.target.value })}
|
||||
className="h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs">Color</Label>
|
||||
<div className="relative">
|
||||
<ColorPicker
|
||||
value={uppercase({ string: draftColorHex })}
|
||||
onChange={(color) => setDraftColorHex(uppercase({ string: color }))}
|
||||
onChangeEnd={(color) =>
|
||||
handleUpdate({ color: `#${uppercase({ string: color })}` })
|
||||
}
|
||||
className="bg-background border"
|
||||
/>
|
||||
{bookmark.color &&
|
||||
bookmark.color.replace(/^#/, "").toUpperCase() !==
|
||||
DEFAULT_BOOKMARK_COLOR.replace(/^#/, "").toUpperCase() && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="text"
|
||||
aria-label="Reset to default color"
|
||||
className="absolute top-1/2 right-1 -translate-y-1/2 mr-1"
|
||||
onClick={() =>
|
||||
editor.scenes.updateBookmark({
|
||||
time,
|
||||
updates: { color: undefined },
|
||||
})
|
||||
}
|
||||
>
|
||||
<HugeiconsIcon
|
||||
icon={ArrowTurnBackwardIcon}
|
||||
className="!size-3.5"
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label className="text-xs">Duration</Label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={bookmark.duration ?? 0}
|
||||
onChange={(event) => {
|
||||
const parsed = parseFloat(event.target.value);
|
||||
const value = Number.isNaN(parsed)
|
||||
? 0
|
||||
: clamp({
|
||||
value: parsed,
|
||||
min: 0,
|
||||
max: Math.max(0, timelineDuration - time),
|
||||
});
|
||||
handleUpdate({ duration: value });
|
||||
}}
|
||||
className="h-8 text-sm"
|
||||
containerClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={handleRemove}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
handleRemove();
|
||||
}
|
||||
}}
|
||||
aria-label="delete bookmark"
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="!size-3.5" />
|
||||
Delete
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { TimelinePlayhead } from "./timeline-playhead";
|
|||
import { SelectionBox } from "../../selection-box";
|
||||
import { useSelectionBox } from "@/hooks/timeline/use-selection-box";
|
||||
import { SnapIndicator } from "./snap-indicator";
|
||||
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
import type { SnapPoint } from "@/lib/timeline/snap-utils";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import {
|
||||
TIMELINE_CONSTANTS,
|
||||
|
|
@ -47,6 +47,8 @@ import { useTimelineSeek } from "@/hooks/timeline/use-timeline-seek";
|
|||
import { useTimelineDragDrop } from "@/hooks/timeline/use-timeline-drag-drop";
|
||||
import { TimelineRuler } from "./timeline-ruler";
|
||||
import { TimelineBookmarksRow } from "./bookmarks";
|
||||
import { useBookmarkDrag } from "@/hooks/timeline/use-bookmark-drag";
|
||||
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
||||
import { useTimelineStore } from "@/stores/timeline-store";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { useTimelinePlayhead } from "@/hooks/timeline/use-timeline-playhead";
|
||||
|
|
@ -126,6 +128,17 @@ export function Timeline() {
|
|||
onSnapPointChange: handleSnapPointChange,
|
||||
});
|
||||
|
||||
const {
|
||||
dragState: bookmarkDragState,
|
||||
handleBookmarkMouseDown,
|
||||
lastMouseXRef: bookmarkLastMouseXRef,
|
||||
} = useBookmarkDrag({
|
||||
zoomLevel,
|
||||
scrollRef: tracksScrollRef,
|
||||
snappingEnabled,
|
||||
onSnapPointChange: handleSnapPointChange,
|
||||
});
|
||||
|
||||
const { handleRulerMouseDown: handlePlayheadRulerMouseDown } =
|
||||
useTimelinePlayhead({
|
||||
zoomLevel,
|
||||
|
|
@ -168,10 +181,18 @@ export function Timeline() {
|
|||
containerWidth,
|
||||
);
|
||||
|
||||
useEdgeAutoScroll({
|
||||
isActive: bookmarkDragState.isDragging,
|
||||
getMouseClientX: () => bookmarkLastMouseXRef.current,
|
||||
rulerScrollRef: tracksScrollRef,
|
||||
tracksScrollRef,
|
||||
contentWidth: dynamicTimelineWidth,
|
||||
});
|
||||
|
||||
const showSnapIndicator =
|
||||
snappingEnabled &&
|
||||
currentSnapPoint !== null &&
|
||||
(dragState.isDragging || isResizing);
|
||||
(dragState.isDragging || bookmarkDragState.isDragging || isResizing);
|
||||
|
||||
const {
|
||||
handleTracksMouseDown,
|
||||
|
|
@ -351,7 +372,7 @@ export function Timeline() {
|
|||
>
|
||||
<div
|
||||
ref={timelineHeaderRef}
|
||||
className="bg-background sticky top-0 z-30 flex flex-col"
|
||||
className="bg-background sticky top-0 flex flex-col"
|
||||
>
|
||||
<TimelineRuler
|
||||
zoomLevel={zoomLevel}
|
||||
|
|
@ -366,6 +387,8 @@ export function Timeline() {
|
|||
<TimelineBookmarksRow
|
||||
zoomLevel={zoomLevel}
|
||||
dynamicTimelineWidth={dynamicTimelineWidth}
|
||||
dragState={bookmarkDragState}
|
||||
onBookmarkMouseDown={handleBookmarkMouseDown}
|
||||
handleWheel={handleWheel}
|
||||
handleTimelineContentClick={handleRulerClick}
|
||||
handleRulerTrackingMouseDown={handleRulerMouseDown}
|
||||
|
|
@ -433,7 +456,7 @@ export function Timeline() {
|
|||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="z-200 w-40">
|
||||
<ContextMenuContent className="w-40">
|
||||
<ContextMenuItem
|
||||
icon={<HugeiconsIcon icon={TaskAdd02Icon} />}
|
||||
onClick={(e) => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useSnapIndicatorPosition } from "@/hooks/timeline/use-snap-indicator-position";
|
||||
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
import type { SnapPoint } from "@/lib/timeline/snap-utils";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
|
||||
interface SnapIndicatorProps {
|
||||
|
|
@ -38,7 +38,7 @@ export function SnapIndicator({
|
|||
|
||||
return (
|
||||
<div
|
||||
className="pointer-events-none absolute z-90"
|
||||
className="pointer-events-none absolute"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: topPosition,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useEditor } from "@/hooks/use-editor";
|
|||
import { useAssetsPanelStore } from "@/stores/assets-panel-store";
|
||||
import AudioWaveform from "./audio-waveform";
|
||||
import { useTimelineElementResize } from "@/hooks/timeline/element/use-element-resize";
|
||||
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
import type { SnapPoint } from "@/lib/timeline/snap-utils";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import {
|
||||
getTrackClasses,
|
||||
|
|
@ -29,6 +29,7 @@ import type { MediaAsset } from "@/types/assets";
|
|||
import { mediaSupportsAudio } from "@/lib/media/media-utils";
|
||||
import { getActionDefinition, type TAction, invokeAction } from "@/lib/actions";
|
||||
import { useElementSelection } from "@/hooks/timeline/element/use-element-selection";
|
||||
import { resolveStickerId } from "@/lib/stickers";
|
||||
import Image from "next/image";
|
||||
import {
|
||||
ScissorIcon,
|
||||
|
|
@ -139,9 +140,7 @@ export function TimelineElement({
|
|||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={`absolute top-0 h-full select-none ${
|
||||
isBeingDragged ? "z-30" : "z-10"
|
||||
}`}
|
||||
className={`absolute top-0 h-full select-none`}
|
||||
style={{
|
||||
left: `${elementLeft}px`,
|
||||
width: `${elementWidth}px`,
|
||||
|
|
@ -155,7 +154,6 @@ export function TimelineElement({
|
|||
element={element}
|
||||
track={track}
|
||||
isSelected={isSelected}
|
||||
isBeingDragged={isBeingDragged}
|
||||
hasAudio={hasAudio}
|
||||
isMuted={isMuted}
|
||||
mediaAssets={mediaAssets}
|
||||
|
|
@ -165,7 +163,7 @@ export function TimelineElement({
|
|||
/>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="z-200 w-64">
|
||||
<ContextMenuContent className="w-64">
|
||||
<ActionMenuItem
|
||||
action="split"
|
||||
icon={<HugeiconsIcon icon={ScissorIcon} />}
|
||||
|
|
@ -227,7 +225,6 @@ function ElementInner({
|
|||
element,
|
||||
track,
|
||||
isSelected,
|
||||
isBeingDragged,
|
||||
hasAudio,
|
||||
isMuted,
|
||||
mediaAssets,
|
||||
|
|
@ -238,7 +235,6 @@ function ElementInner({
|
|||
element: TimelineElementType;
|
||||
track: TimelineTrack;
|
||||
isSelected: boolean;
|
||||
isBeingDragged: boolean;
|
||||
hasAudio: boolean;
|
||||
isMuted: boolean;
|
||||
mediaAssets: MediaAsset[];
|
||||
|
|
@ -248,7 +244,7 @@ function ElementInner({
|
|||
element: TimelineElementType,
|
||||
) => void;
|
||||
handleResizeStart: (params: {
|
||||
e: React.MouseEvent;
|
||||
event: React.MouseEvent;
|
||||
elementId: string;
|
||||
side: "left" | "right";
|
||||
}) => void;
|
||||
|
|
@ -259,7 +255,7 @@ function ElementInner({
|
|||
{
|
||||
type: track.type,
|
||||
},
|
||||
)} ${isBeingDragged ? "z-30" : "z-10"} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
|
||||
)} ${canElementBeHidden(element) && element.hidden ? "opacity-50" : ""}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -321,7 +317,7 @@ function ResizeHandle({
|
|||
side: "left" | "right";
|
||||
elementId: string;
|
||||
handleResizeStart: (params: {
|
||||
e: React.MouseEvent;
|
||||
event: React.MouseEvent;
|
||||
elementId: string;
|
||||
side: "left" | "right";
|
||||
}) => void;
|
||||
|
|
@ -330,8 +326,8 @@ function ResizeHandle({
|
|||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`bg-primary absolute top-0 bottom-0 z-50 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
|
||||
onMouseDown={(e) => handleResizeStart({ e, elementId, side })}
|
||||
className={`bg-primary absolute top-0 bottom-0 flex w-[0.6rem] items-center justify-center ${isLeft ? "left-0 cursor-w-resize" : "right-0 cursor-e-resize"}`}
|
||||
onMouseDown={(event) => handleResizeStart({ event, elementId, side })}
|
||||
aria-label={`${isLeft ? "Left" : "Right"} resize handle`}
|
||||
>
|
||||
<div className="bg-foreground h-[1.5rem] w-[0.2rem] rounded-full" />
|
||||
|
|
@ -362,7 +358,10 @@ function ElementContent({
|
|||
return (
|
||||
<div className="flex size-full items-center gap-2 pl-2">
|
||||
<Image
|
||||
src={`https://api.iconify.design/${element.iconName}.svg?width=20&height=20`}
|
||||
src={resolveStickerId({
|
||||
stickerId: element.stickerId,
|
||||
options: { width: 20, height: 20 },
|
||||
})}
|
||||
alt={element.name}
|
||||
className="size-5 shrink-0"
|
||||
width={20}
|
||||
|
|
|
|||
|
|
@ -72,20 +72,22 @@ export function TimelinePlayhead({
|
|||
aria-valuemax={duration}
|
||||
aria-valuenow={playheadPosition}
|
||||
tabIndex={0}
|
||||
className="pointer-events-auto absolute z-60"
|
||||
className="pointer-events-none absolute z-5"
|
||||
style={{
|
||||
left: `${leftPosition}px`,
|
||||
top: 0,
|
||||
height: `${totalHeight}px`,
|
||||
width: "2px",
|
||||
}}
|
||||
onMouseDown={handlePlayheadMouseDown}
|
||||
onKeyDown={handlePlayheadKeyDown}
|
||||
>
|
||||
<div className="bg-foreground absolute left-0 h-full w-0.5 cursor-col-resize" />
|
||||
<div className="bg-foreground pointer-events-none absolute left-0 h-full w-0.5" />
|
||||
|
||||
<div
|
||||
className={`absolute top-1 left-1/2 size-3 -translate-x-1/2 transform rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Drag playhead"
|
||||
className={`pointer-events-auto absolute top-1 left-1/2 size-3 -translate-x-1/2 transform cursor-col-resize rounded-full border-2 shadow-xs ${isSnappingToPlayhead ? "bg-foreground border-foreground" : "bg-foreground border-foreground/50"}`}
|
||||
onMouseDown={handlePlayheadMouseDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ function SceneSelector() {
|
|||
<SplitButtonLeft>{currentScene?.name || "No Scene"}</SplitButtonLeft>
|
||||
<SplitButtonSeparator />
|
||||
<ScenesView>
|
||||
<SplitButtonRight onClick={() => {}} type="button">
|
||||
<SplitButtonRight onClick={() => {}}>
|
||||
<HugeiconsIcon icon={Layers01Icon} className="size-4" />
|
||||
</SplitButtonRight>
|
||||
</ScenesView>
|
||||
|
|
@ -222,7 +222,6 @@ function ToolbarRightSection({
|
|||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => onZoom({ direction: "out" })}
|
||||
>
|
||||
<HugeiconsIcon icon={SearchMinusIcon} />
|
||||
|
|
@ -240,7 +239,6 @@ function ToolbarRightSection({
|
|||
<Button
|
||||
variant="text"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={() => onZoom({ direction: "in" })}
|
||||
>
|
||||
<HugeiconsIcon icon={SearchAddIcon} />
|
||||
|
|
@ -269,7 +267,6 @@ function ToolbarButton({
|
|||
<Button
|
||||
variant={isActive ? "secondary" : "text"}
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={(event) => onClick({ event })}
|
||||
className={cn(
|
||||
"rounded-sm",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { useElementSelection } from "@/hooks/timeline/element/use-element-select
|
|||
import { TimelineElement } from "./timeline-element";
|
||||
import type { TimelineTrack } from "@/types/timeline";
|
||||
import type { TimelineElement as TimelineElementType } from "@/types/timeline";
|
||||
import type { SnapPoint } from "@/hooks/timeline/use-timeline-snapping";
|
||||
import type { SnapPoint } from "@/lib/timeline/snap-utils";
|
||||
import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants";
|
||||
import { useEdgeAutoScroll } from "@/hooks/timeline/use-edge-auto-scroll";
|
||||
import type { ElementDragState } from "@/types/timeline";
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import { Button } from "./ui/button";
|
||||
import Link from "next/link";
|
||||
import { SOCIAL_LINKS } from "@/constants/site-constants";
|
||||
import {
|
||||
GithubIcon,
|
||||
Link04Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { GithubIcon, Link04Icon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
||||
export function GitHubContributeSection({
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
useKeybindingDisabler,
|
||||
} from "@/hooks/use-keybindings";
|
||||
import { useEditorActions } from "@/hooks/actions/use-editor-actions";
|
||||
import { prefetchFontAtlas } from "@/lib/fonts/google-fonts";
|
||||
|
||||
interface EditorProviderProps {
|
||||
projectId: string;
|
||||
|
|
@ -42,6 +43,7 @@ export function EditorProvider({ projectId, children }: EditorProviderProps) {
|
|||
if (cancelled) return;
|
||||
|
||||
setIsLoading(false);
|
||||
prefetchFontAtlas();
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,36 +1,39 @@
|
|||
"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="ghost"
|
||||
className={cn("size-8", 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="ghost"
|
||||
className={cn("size-8", 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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Alert02Icon } from "@hugeicons/core-free-icons";
|
||||
|
|
@ -32,7 +31,10 @@ const Alert = React.forwardRef<
|
|||
{...props}
|
||||
>
|
||||
{variant === "destructive" && (
|
||||
<HugeiconsIcon icon={Alert02Icon} className="size-5 text-destructive mt-0.5" />
|
||||
<HugeiconsIcon
|
||||
icon={Alert02Icon}
|
||||
className="size-5 text-destructive mt-0.5"
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
|
|
@ -45,7 +47,10 @@ const AlertTitle = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-2 text-base leading-none font-semibold tracking-tight", className)}
|
||||
className={cn(
|
||||
"mb-2 text-base leading-none font-semibold tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
|
|
|||
|
|
@ -38,10 +38,10 @@ const AvatarFallback = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className,
|
||||
)}
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
|
|
|||
|
|
@ -1,108 +1,99 @@
|
|||
import * as React from "react";
|
||||
import { Slot as SlotPrimitive } from "radix-ui";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
import { Slot } from "radix-ui";
|
||||
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
React.ComponentPropsWithoutRef<"nav"> & {
|
||||
separator?: React.ReactNode;
|
||||
}
|
||||
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />);
|
||||
Breadcrumb.displayName = "Breadcrumb";
|
||||
|
||||
const BreadcrumbList = React.forwardRef<
|
||||
HTMLOListElement,
|
||||
React.ComponentPropsWithoutRef<"ol">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbList.displayName = "BreadcrumbList";
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentPropsWithoutRef<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem";
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentPropsWithoutRef<"a"> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? SlotPrimitive.Slot : "a";
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
className={cn("hover:text-foreground", className)}
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink";
|
||||
}
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<
|
||||
HTMLSpanElement,
|
||||
React.ComponentPropsWithoutRef<"span">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<span
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage";
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const BreadcrumbSeparator = ({
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot.Root : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) => (
|
||||
<li
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator";
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
const BreadcrumbEllipsis = ({
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import * as React from "react";
|
||||
import { Slot as SlotPrimitive } from "radix-ui";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const buttonVariants = cva(
|
||||
|
|
@ -9,18 +8,14 @@ const buttonVariants = cva(
|
|||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
background:
|
||||
"bg-background text-foreground hover:bg-background/90",
|
||||
foreground:
|
||||
"bg-foreground text-background hover:bg-foreground/90",
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
background: "bg-background text-foreground hover:bg-background/90",
|
||||
foreground: "bg-foreground text-background hover:bg-foreground/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
"destructive-foreground":
|
||||
"border bg-background hover:bg-destructive/15 text-destructive",
|
||||
outline:
|
||||
"border border-border bg-transparent hover:bg-accent/50",
|
||||
outline: "border border-border bg-transparent hover:bg-accent/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground border border-secondary-border",
|
||||
text: "bg-transparent rounded-none opacity-100 hover:opacity-75",
|
||||
|
|
@ -28,10 +23,10 @@ const buttonVariants = cva(
|
|||
link: "text-primary underline-offset-4 hover:underline !p-0 !h-auto",
|
||||
},
|
||||
size: {
|
||||
default: "h-9.5 px-4 py-2",
|
||||
sm: "h-8 p-1 px-2 text-xs rounded-sm",
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-7.5 p-1 px-2.5 text-sm rounded-sm",
|
||||
lg: "h-10 p-5 px-6",
|
||||
icon: "size-7",
|
||||
icon: "size-7 rounded-sm",
|
||||
text: "p-0",
|
||||
},
|
||||
},
|
||||
|
|
@ -54,8 +49,11 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||
const effectiveSize = size ?? (variant === "text" ? "text" : "default");
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size: effectiveSize, className }))}
|
||||
className={cn(
|
||||
buttonVariants({ variant, size: effectiveSize, className }),
|
||||
)}
|
||||
ref={ref}
|
||||
type="button"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -60,8 +60,8 @@ function Calendar({
|
|||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ ...props }) => <ChevronLeft className="size-4" />,
|
||||
IconRight: ({ ...props }) => <ChevronRight className="size-4" />,
|
||||
IconLeft: () => <ChevronLeft className="size-4" />,
|
||||
IconRight: () => <ChevronRight className="size-4" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,262 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/utils/ui";
|
||||
import { Button } from "./button";
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
type CarouselPlugin = UseCarouselParameters[1];
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugin;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
};
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins,
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev());
|
||||
setCanScrollNext(api.canScrollNext());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev();
|
||||
}, [api]);
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext();
|
||||
}, [api]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
setApi(api);
|
||||
}, [api, setApi]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(api);
|
||||
api.on("reInit", onSelect);
|
||||
api.on("select", onSelect);
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect);
|
||||
};
|
||||
}, [api, onSelect]);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
Carousel.displayName = "Carousel";
|
||||
|
||||
const CarouselContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
CarouselContent.displayName = "CarouselContent";
|
||||
|
||||
const CarouselItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
CarouselItem.displayName = "CarouselItem";
|
||||
|
||||
const CarouselPrevious = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
CarouselPrevious.displayName = "CarouselPrevious";
|
||||
|
||||
const CarouselNext = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="size-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
});
|
||||
CarouselNext.displayName = "CarouselNext";
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
};
|
||||
|
|
@ -1,370 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as RechartsPrimitive from "recharts";
|
||||
import {
|
||||
NameType,
|
||||
Payload,
|
||||
ValueType,
|
||||
} from "recharts/types/component/DefaultTooltipContent";
|
||||
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const;
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode;
|
||||
icon?: React.ComponentType;
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
);
|
||||
};
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig;
|
||||
};
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig;
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"];
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId();
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
);
|
||||
});
|
||||
ChartContainer.displayName = "Chart";
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([_, config]) => config.theme || config.color,
|
||||
);
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color;
|
||||
return color ? ` --color-${key}: ${color};` : null;
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`,
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
indicator?: "line" | "dot" | "dashed";
|
||||
nameKey?: string;
|
||||
labelKey?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [item] = payload;
|
||||
const key = `${labelKey || item.dataKey || item.name || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label;
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
]);
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-border/50 bg-background grid min-w-32 items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{nestLabel ? null : tooltipLabel}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload.fill || item.color;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5",
|
||||
indicator === "dot" && "items-center",
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
|
||||
{
|
||||
"size-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
},
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center",
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="text-foreground font-mono font-medium tabular-nums">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChartTooltipContent.displayName = "ChartTooltip";
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend;
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean;
|
||||
nameKey?: string;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
||||
ref,
|
||||
) => {
|
||||
const { config } = useChart();
|
||||
|
||||
if (!payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:size-3",
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="size-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
ChartLegendContent.displayName = "ChartLegend";
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string,
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined;
|
||||
|
||||
let configLabelKey: string = key;
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string;
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config];
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
};
|
||||
|
|
@ -1,133 +1,80 @@
|
|||
import { forwardRef, useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { Input } from "./input";
|
||||
import {
|
||||
Popover,
|
||||
PopoverClose,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "./popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "./select";
|
||||
import { Button } from "./button";
|
||||
import { Cancel01Icon, ColorPickerIcon } from "@hugeicons/core-free-icons";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
type ColorFormat,
|
||||
appendAlpha,
|
||||
extractColorFromText,
|
||||
formatColorValue,
|
||||
hexToHsv,
|
||||
hsvToHex,
|
||||
parseColorInput,
|
||||
parseHexAlpha,
|
||||
} from "@/utils/color";
|
||||
|
||||
interface ColorPickerProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onChangeEnd?: (value: string) => void;
|
||||
className?: string;
|
||||
containerRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
const hexToHsv = (hex: string) => {
|
||||
const r = parseInt(hex.slice(0, 2), 16) / 255;
|
||||
const g = parseInt(hex.slice(2, 4), 16) / 255;
|
||||
const b = parseInt(hex.slice(4, 6), 16) / 255;
|
||||
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const diff = max - min;
|
||||
|
||||
let h = 0;
|
||||
const s = max === 0 ? 0 : diff / max;
|
||||
const v = max;
|
||||
|
||||
if (diff !== 0) {
|
||||
switch (max) {
|
||||
case r:
|
||||
h = ((g - b) / diff) % 6;
|
||||
break;
|
||||
case g:
|
||||
h = (b - r) / diff + 2;
|
||||
break;
|
||||
case b:
|
||||
h = (r - g) / diff + 4;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
h = (h * 60 + 360) % 360;
|
||||
if (Number.isNaN(h)) h = 0;
|
||||
|
||||
return [h, s, v];
|
||||
};
|
||||
|
||||
const hsvToHex = (h: number, s: number, v: number) => {
|
||||
const c = v * s;
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
|
||||
const m = v - c;
|
||||
|
||||
let r = 0,
|
||||
g = 0,
|
||||
b = 0;
|
||||
|
||||
if (h >= 0 && h < 60) {
|
||||
r = c;
|
||||
g = x;
|
||||
b = 0;
|
||||
} else if (h >= 60 && h < 120) {
|
||||
r = x;
|
||||
g = c;
|
||||
b = 0;
|
||||
} else if (h >= 120 && h < 180) {
|
||||
r = 0;
|
||||
g = c;
|
||||
b = x;
|
||||
} else if (h >= 180 && h < 240) {
|
||||
r = 0;
|
||||
g = x;
|
||||
b = c;
|
||||
} else if (h >= 240 && h < 300) {
|
||||
r = x;
|
||||
g = 0;
|
||||
b = c;
|
||||
} else if (h >= 300 && h < 360) {
|
||||
r = c;
|
||||
g = 0;
|
||||
b = x;
|
||||
}
|
||||
|
||||
r = Math.round((r + m) * 255);
|
||||
g = Math.round((g + m) * 255);
|
||||
b = Math.round((b + m) * 255);
|
||||
|
||||
return [r, g, b].map((x) => x.toString(16).padStart(2, "0")).join("");
|
||||
};
|
||||
|
||||
const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
||||
({ className, value = "FFFFFF", onChange, onChangeEnd, containerRef, ...props }, ref) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState<"saturation" | "hue" | null>(
|
||||
null,
|
||||
);
|
||||
const [pickerPosition, setPickerPosition] = useState({
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
});
|
||||
({ className, value = "FFFFFF", onChange, onChangeEnd, ...props }, ref) => {
|
||||
const [isDragging, setIsDragging] = useState<
|
||||
"saturation" | "hue" | "opacity" | null
|
||||
>(null);
|
||||
const [internalHue, setInternalHue] = useState(0);
|
||||
const [inputValue, setInputValue] = useState(value);
|
||||
const [colorFormat, setColorFormat] = useState<ColorFormat>("hex");
|
||||
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
const saturationRef = useRef<HTMLButtonElement>(null);
|
||||
const hueRef = useRef<HTMLButtonElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const opacityRef = useRef<HTMLButtonElement>(null);
|
||||
const latestDragColorRef = useRef<string | null>(null);
|
||||
|
||||
const [h, s, v] = hexToHsv(value);
|
||||
const displayHue = s > 0 ? h : internalHue;
|
||||
const isEyeDropperSupported =
|
||||
typeof window !== "undefined" && "EyeDropper" in window;
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(value);
|
||||
}, [value]);
|
||||
const { rgb: rgbValue, alpha } = parseHexAlpha({ hex: value });
|
||||
const [h, s, v] = hexToHsv({ hex: rgbValue });
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
pickerRef.current &&
|
||||
!pickerRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () =>
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
const handleEyeDropper = async () => {
|
||||
if (!isEyeDropperSupported || !EyeDropper) return;
|
||||
try {
|
||||
const dropper = new EyeDropper();
|
||||
const result = await dropper.open();
|
||||
const hex = result.sRGBHex.replace("#", "").toLowerCase();
|
||||
const finalHex = appendAlpha({ rgbHex: hex, alpha });
|
||||
onChange?.(finalHex);
|
||||
onChangeEnd?.(finalHex);
|
||||
} catch {
|
||||
// user cancelled the picker
|
||||
}
|
||||
}, [isOpen]);
|
||||
};
|
||||
const hueDiff = Math.abs(h - internalHue);
|
||||
const isSameHueWrapped = hueDiff < 1 || Math.abs(hueDiff - 360) < 1;
|
||||
const displayHue = s === 0 || isSameHueWrapped ? internalHue : h;
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(formatColorValue({ hex: value, format: colorFormat }));
|
||||
}, [value, colorFormat]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
|
|
@ -143,9 +90,10 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||
0,
|
||||
Math.min(1, (e.clientY - rect.top) / rect.height),
|
||||
);
|
||||
const newS = x;
|
||||
const newV = 1 - y;
|
||||
const newHex = hsvToHex(displayHue, newS, newV);
|
||||
const newHex = appendAlpha({
|
||||
rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }),
|
||||
alpha,
|
||||
});
|
||||
latestDragColorRef.current = newHex;
|
||||
onChange?.(newHex);
|
||||
}
|
||||
|
|
@ -159,11 +107,25 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||
const newH = x * 360;
|
||||
setInternalHue(newH);
|
||||
if (s > 0) {
|
||||
const newHex = hsvToHex(newH, s, v);
|
||||
const newHex = appendAlpha({
|
||||
rgbHex: hsvToHex({ h: newH, s, v }),
|
||||
alpha,
|
||||
});
|
||||
latestDragColorRef.current = newHex;
|
||||
onChange?.(newHex);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDragging === "opacity" && opacityRef.current) {
|
||||
const rect = opacityRef.current.getBoundingClientRect();
|
||||
const x = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width),
|
||||
);
|
||||
const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x });
|
||||
latestDragColorRef.current = newHex;
|
||||
onChange?.(newHex);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
|
|
@ -182,7 +144,7 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}
|
||||
}, [isDragging, displayHue, s, v, onChange]);
|
||||
}, [isDragging, displayHue, s, v, alpha, rgbValue, onChange, onChangeEnd]);
|
||||
|
||||
const handleSaturationMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -192,9 +154,10 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||
const rect = saturationElement.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height));
|
||||
const newS = x;
|
||||
const newV = 1 - y;
|
||||
const newHex = hsvToHex(displayHue, newS, newV);
|
||||
const newHex = appendAlpha({
|
||||
rgbHex: hsvToHex({ h: displayHue, s: x, v: 1 - y }),
|
||||
alpha,
|
||||
});
|
||||
latestDragColorRef.current = newHex;
|
||||
onChange?.(newHex);
|
||||
};
|
||||
|
|
@ -209,28 +172,83 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||
const newH = x * 360;
|
||||
setInternalHue(newH);
|
||||
if (s > 0) {
|
||||
const newHex = hsvToHex(newH, s, v);
|
||||
const newHex = appendAlpha({
|
||||
rgbHex: hsvToHex({ h: newH, s, v }),
|
||||
alpha,
|
||||
});
|
||||
latestDragColorRef.current = newHex;
|
||||
onChange?.(newHex);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpacityMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const opacityElement = opacityRef.current;
|
||||
if (!opacityElement) return;
|
||||
setIsDragging("opacity");
|
||||
const rect = opacityElement.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const newHex = appendAlpha({ rgbHex: rgbValue, alpha: x });
|
||||
latestDragColorRef.current = newHex;
|
||||
onChange?.(newHex);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const hex = e.target.value.replace("#", "");
|
||||
setInputValue(hex);
|
||||
setInputValue(
|
||||
colorFormat === "hex"
|
||||
? e.target.value.replace("#", "")
|
||||
: e.target.value,
|
||||
);
|
||||
};
|
||||
|
||||
const commitInputValue = () => {
|
||||
const parsed = parseColorInput({
|
||||
input: inputValue,
|
||||
format: colorFormat,
|
||||
});
|
||||
if (parsed) {
|
||||
const nextHex = appendAlpha({ rgbHex: parsed, alpha });
|
||||
onChange?.(nextHex);
|
||||
onChangeEnd?.(nextHex);
|
||||
return;
|
||||
}
|
||||
|
||||
const extracted = extractColorFromText({ text: inputValue });
|
||||
if (extracted) {
|
||||
const hasExplicitAlpha = extracted.length > 6;
|
||||
const finalHex = hasExplicitAlpha
|
||||
? extracted
|
||||
: appendAlpha({ rgbHex: extracted, alpha });
|
||||
onChange?.(finalHex);
|
||||
onChangeEnd?.(finalHex);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
onChange?.(inputValue);
|
||||
commitInputValue();
|
||||
};
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
onChange?.(inputValue);
|
||||
commitInputValue();
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (event: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const pastedText = event.clipboardData.getData("text");
|
||||
const extractedHex = extractColorFromText({ text: pastedText });
|
||||
if (!extractedHex) return;
|
||||
|
||||
event.preventDefault();
|
||||
const hasExplicitAlpha = extractedHex.length > 6;
|
||||
const finalHex = hasExplicitAlpha
|
||||
? extractedHex
|
||||
: appendAlpha({ rgbHex: extractedHex, alpha });
|
||||
onChange?.(finalHex);
|
||||
onChangeEnd?.(finalHex);
|
||||
};
|
||||
|
||||
const saturationStyle = {
|
||||
background: `linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, hsl(${displayHue}, 100%, 50%))`,
|
||||
};
|
||||
|
|
@ -240,90 +258,178 @@ const ColorPicker = forwardRef<HTMLDivElement, ColorPickerProps>(
|
|||
"linear-gradient(to right, #f00 0%, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, #f00 100%)",
|
||||
};
|
||||
|
||||
const checkerboardStyle = {
|
||||
backgroundImage: `
|
||||
linear-gradient(45deg, rgba(0,0,0,0.1) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, rgba(0,0,0,0.1) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, rgba(0,0,0,0.1) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, rgba(0,0,0,0.1) 75%)
|
||||
`,
|
||||
backgroundSize: "8px 8px",
|
||||
backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px",
|
||||
backgroundColor: "#fff",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex-1">
|
||||
<Popover>
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-accent flex h-8 items-center gap-2 rounded-md px-[0.45rem]",
|
||||
"bg-accent flex h-8 flex-1 items-center gap-2 rounded-md px-[0.45rem]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
className="size-4.5 cursor-pointer border rounded-sm hover:ring-2 hover:ring-white/20"
|
||||
style={{ backgroundColor: `#${value}` }}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isOpen && triggerRef.current && containerRef?.current) {
|
||||
const containerRect =
|
||||
containerRef.current.getBoundingClientRect();
|
||||
setPickerPosition({
|
||||
right: window.innerWidth - containerRect.left - 8,
|
||||
bottom: window.innerHeight - containerRect.bottom,
|
||||
});
|
||||
}
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
/>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="size-4.5 cursor-pointer border rounded-sm hover:ring-1 hover:ring-foreground/20"
|
||||
style={{ backgroundColor: `#${value}` }}
|
||||
type="button"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<div className="flex flex-1 items-center">
|
||||
<Input
|
||||
className="!border-0 bg-transparent p-0 !ring-0 !ring-offset-0 uppercase"
|
||||
className={cn(
|
||||
"!border-0 bg-transparent p-0 !ring-0 !ring-offset-0",
|
||||
colorFormat === "hex" && "uppercase",
|
||||
)}
|
||||
size="sm"
|
||||
containerClassName="w-full"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputBlur}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={pickerRef}
|
||||
className="bg-popover border-border fixed z-50 rounded-lg border p-4 shadow-lg select-none"
|
||||
style={{
|
||||
right: pickerPosition.right,
|
||||
bottom: pickerPosition.bottom,
|
||||
}}
|
||||
<PopoverContent
|
||||
className="w-64 px-0 select-none flex flex-col gap-3 py-2"
|
||||
side="left"
|
||||
sideOffset={8}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(event) => {
|
||||
if (isDragging) event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<header className="border-b flex justify-between items-center pb-2 px-2">
|
||||
<Select defaultValue="custom">
|
||||
<SelectTrigger variant="outline">
|
||||
<SelectValue placeholder="Select a mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
<SelectItem value="saved">Saved</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div>
|
||||
{isEyeDropperSupported && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
onClick={handleEyeDropper}
|
||||
>
|
||||
<HugeiconsIcon icon={ColorPickerIcon} />
|
||||
</Button>
|
||||
)}
|
||||
<PopoverClose asChild>
|
||||
<Button variant="ghost" size="icon" type="button">
|
||||
<HugeiconsIcon icon={Cancel01Icon} />
|
||||
</Button>
|
||||
</PopoverClose>
|
||||
</div>
|
||||
</header>
|
||||
<div className="px-2 flex flex-col gap-3">
|
||||
<button
|
||||
ref={saturationRef}
|
||||
className="relative h-44 aspect-square w-full appearance-none border-0 bg-transparent p-0"
|
||||
style={saturationStyle}
|
||||
type="button"
|
||||
onMouseDown={handleSaturationMouseDown}
|
||||
>
|
||||
<button
|
||||
ref={saturationRef}
|
||||
className="relative mb-3 h-32 w-48 cursor-crosshair appearance-none border-0 bg-transparent p-0"
|
||||
style={saturationStyle}
|
||||
type="button"
|
||||
onMouseDown={handleSaturationMouseDown}
|
||||
>
|
||||
<ColorCircle
|
||||
size="sm"
|
||||
position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }}
|
||||
color={`#${value}`}
|
||||
/>
|
||||
</button>
|
||||
<ColorCircle
|
||||
size="sm"
|
||||
position={{ left: `${s * 100}%`, top: `${(1 - v) * 100}%` }}
|
||||
color={`#${value}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
ref={hueRef}
|
||||
className="relative h-4 w-48 cursor-pointer rounded-lg appearance-none border-0 bg-transparent p-0"
|
||||
style={hueStyle}
|
||||
type="button"
|
||||
onMouseDown={handleHueMouseDown}
|
||||
<button
|
||||
ref={hueRef}
|
||||
className="relative h-4 w-full rounded-lg appearance-none border-0 bg-transparent p-0"
|
||||
style={hueStyle}
|
||||
type="button"
|
||||
onMouseDown={handleHueMouseDown}
|
||||
>
|
||||
<ColorCircle
|
||||
size="md"
|
||||
position={{
|
||||
left: `calc(0.5rem + (100% - 1rem) * ${displayHue / 360})`,
|
||||
top: "50%",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
ref={opacityRef}
|
||||
className="relative h-4 w-full overflow-hidden rounded-lg appearance-none border-0 p-0"
|
||||
style={checkerboardStyle}
|
||||
type="button"
|
||||
onMouseDown={handleOpacityMouseDown}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 rounded-lg"
|
||||
style={{
|
||||
background: `linear-gradient(to right, transparent, #${rgbValue})`,
|
||||
}}
|
||||
/>
|
||||
<ColorCircle
|
||||
size="md"
|
||||
position={{
|
||||
left: `calc(0.5rem + (100% - 1rem) * ${alpha})`,
|
||||
top: "50%",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={colorFormat}
|
||||
onValueChange={(value) => setColorFormat(value as ColorFormat)}
|
||||
>
|
||||
<ColorCircle
|
||||
size="md"
|
||||
position={{
|
||||
left: `${(displayHue / 360) * 100}%`,
|
||||
top: "50%",
|
||||
}}
|
||||
color={`#${value}`}
|
||||
/>
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
<SelectTrigger variant="outline" className="min-w-18 max-w-18">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hex">HEX</SelectItem>
|
||||
<SelectItem value="rgb">RGB</SelectItem>
|
||||
<SelectItem value="hsl">HSL</SelectItem>
|
||||
<SelectItem value="hsv">HSV</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Input
|
||||
className={cn(
|
||||
"h-7 rounded-sm p-2.5",
|
||||
colorFormat === "hex" && "uppercase",
|
||||
)}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputBlur}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onPaste={handlePaste}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -336,7 +442,7 @@ const ColorCircle = ({
|
|||
}: {
|
||||
size: "sm" | "md";
|
||||
position: { left: string; top: string };
|
||||
color: string;
|
||||
color?: string;
|
||||
}) => (
|
||||
<div
|
||||
className={`pointer-events-none absolute rounded-full border-3 border-white shadow-lg ${
|
||||
|
|
|
|||
|
|
@ -1,152 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { DialogProps } from "@radix-ui/react-dialog";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { Search } from "lucide-react";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { Dialog, DialogContent } from "./dialog";
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex size-full flex-col overflow-hidden rounded-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:size-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:size-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-x-hidden overflow-y-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-popover-hover data-[selected=true]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
CommandShortcut.displayName = "CommandShortcut";
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
|
|
@ -5,7 +5,11 @@ import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
|
|||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Tick02Icon, ArrowRightIcon, CircleIcon } from "@hugeicons/core-free-icons"
|
||||
import {
|
||||
Tick02Icon,
|
||||
ArrowRightIcon,
|
||||
CircleIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root;
|
||||
|
||||
|
|
@ -20,12 +24,14 @@ const ContextMenuSub = ContextMenuPrimitive.Sub;
|
|||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||
|
||||
const contextMenuItemVariants = cva(
|
||||
"relative flex cursor-pointer select-none items-center gap-2.5 px-4 py-1.5 last:pb-1 text-base outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"relative flex cursor-pointer select-none items-center gap-2.5 px-4 py-1.5 text-sm outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "focus:bg-accent/35 focus:text-accent-foreground [&_svg]:text-muted-foreground",
|
||||
destructive: "text-destructive focus:bg-destructive/5 focus:text-destructive [&_svg]:text-destructive",
|
||||
default:
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg]:text-muted-foreground",
|
||||
destructive:
|
||||
"text-destructive focus:bg-destructive/5 focus:text-destructive [&_svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
@ -41,22 +47,32 @@ const ContextMenuSubTrigger = React.forwardRef<
|
|||
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
>(({ className, inset, children, variant = "default", icon, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
contextMenuItemVariants({ variant }),
|
||||
"data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>}
|
||||
{children}
|
||||
<HugeiconsIcon icon={ArrowRightIcon} className="ml-auto text-muted-foreground/80" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
));
|
||||
>(
|
||||
(
|
||||
{ className, inset, children, variant = "default", icon, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
contextMenuItemVariants({ variant }),
|
||||
"data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon && (
|
||||
<span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
|
||||
)}
|
||||
{children}
|
||||
<HugeiconsIcon
|
||||
icon={ArrowRightIcon}
|
||||
className="ml-auto text-muted-foreground/80"
|
||||
/>
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
),
|
||||
);
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
|
|
@ -76,13 +92,15 @@ ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
|||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & {
|
||||
container?: HTMLElement | null;
|
||||
}
|
||||
>(({ className, container, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal container={container ?? undefined}>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-2.5",
|
||||
"bg-popover text-popover-foreground z-50 min-w-48 overflow-hidden rounded-lg border shadow-xl py-1.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -99,25 +117,46 @@ const ContextMenuItem = React.forwardRef<
|
|||
icon?: React.ReactNode;
|
||||
textRight?: string;
|
||||
}
|
||||
>(({ className, inset, variant = "default", icon, children, textRight, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
contextMenuItemVariants({ variant }),
|
||||
inset && "pl-8",
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon && <span className="[&_svg]:size-4 [&_svg]:shrink-0">{icon}</span>}
|
||||
{children}
|
||||
{textRight && (
|
||||
<span className="ml-auto text-[0.60rem] tracking-widest text-muted-foreground/80 mb-0.5">
|
||||
{textRight}
|
||||
</span>
|
||||
)}
|
||||
</ContextMenuPrimitive.Item>
|
||||
));
|
||||
inset,
|
||||
variant = "default",
|
||||
icon,
|
||||
children,
|
||||
textRight,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const shouldInsetContent = inset || Boolean(icon);
|
||||
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
contextMenuItemVariants({ variant }),
|
||||
shouldInsetContent && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon && (
|
||||
<span className="absolute left-3 flex size-3.5 items-center justify-center text-muted-foreground [&_svg]:size-4 [&_svg]:shrink-0">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
{textRight && (
|
||||
<span className="ml-auto text-[0.60rem] tracking-widest text-muted-foreground/80 mb-0.5">
|
||||
{textRight}
|
||||
</span>
|
||||
)}
|
||||
</ContextMenuPrimitive.Item>
|
||||
);
|
||||
},
|
||||
);
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
|
|
@ -126,22 +165,33 @@ const ContextMenuCheckboxItem = React.forwardRef<
|
|||
variant?: VariantProps<typeof contextMenuItemVariants>["variant"];
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
>(({ className, children, checked, variant = "default", icon, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(contextMenuItemVariants({ variant }), "pr-2 pl-8", className)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<HugeiconsIcon icon={Tick02Icon} className="size-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>}
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
>(
|
||||
(
|
||||
{ className, children, checked, variant = "default", icon, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
contextMenuItemVariants({ variant }),
|
||||
"pr-2 pl-8",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-3 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<HugeiconsIcon icon={Tick02Icon} className="size-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{icon && (
|
||||
<span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
|
||||
)}
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
),
|
||||
);
|
||||
ContextMenuCheckboxItem.displayName =
|
||||
ContextMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
|
|
@ -162,7 +212,9 @@ const ContextMenuRadioItem = React.forwardRef<
|
|||
<HugeiconsIcon icon={CircleIcon} className="size-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>}
|
||||
{icon && (
|
||||
<span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
|
||||
)}
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
));
|
||||
|
|
@ -184,7 +236,9 @@ const ContextMenuLabel = React.forwardRef<
|
|||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon && <span className="size-4 shrink-0 text-muted-foreground">{icon}</span>}
|
||||
{icon && (
|
||||
<span className="size-4 shrink-0 text-muted-foreground">{icon}</span>
|
||||
)}
|
||||
{children}
|
||||
</ContextMenuPrimitive.Label>
|
||||
));
|
||||
|
|
@ -208,7 +262,10 @@ const ContextMenuShortcut = ({
|
|||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest text-muted-foreground opacity-60", className)}
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground opacity-60",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,118 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Drawer as DrawerPrimitive } from "vaul";
|
||||
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||
<DrawerPrimitive.Root
|
||||
shouldScaleBackground={shouldScaleBackground}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Drawer.displayName = "Drawer";
|
||||
|
||||
const DrawerTrigger = DrawerPrimitive.Trigger;
|
||||
|
||||
const DrawerPortal = DrawerPrimitive.Portal;
|
||||
|
||||
const DrawerClose = DrawerPrimitive.Close;
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn("fixed inset-0 z-50 bg-black/80", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName;
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-background fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 h-2 w-[100px] rounded-full" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
));
|
||||
DrawerContent.displayName = "DrawerContent";
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerHeader.displayName = "DrawerHeader";
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DrawerFooter.displayName = "DrawerFooter";
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg leading-none font-semibold tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerTitle.displayName = DrawerPrimitive.Title.displayName;
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DrawerPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DrawerDescription.displayName = DrawerPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
|
|
@ -20,7 +20,7 @@ const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
|||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
|
||||
|
||||
const dropdownMenuItemVariants = cva(
|
||||
"relative flex cursor-pointer select-none items-center gap-2 rounded-lg px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"relative flex cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-sm text-foreground outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
|
@ -66,7 +66,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
|||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg",
|
||||
"group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -88,7 +88,7 @@ const DropdownMenuContent = React.forwardRef<
|
|||
e.preventDefault();
|
||||
}}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-lg border p-2 shadow-lg",
|
||||
"group/menu bg-popover text-popover-foreground z-50 min-w-32 overflow-hidden rounded-lg border p-1.5 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -101,19 +101,61 @@ const DropdownMenuItem = React.forwardRef<
|
|||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
variant?: VariantProps<typeof dropdownMenuItemVariants>["variant"];
|
||||
}
|
||||
>(({ className, inset, variant = "default", ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
dropdownMenuItemVariants({ variant }),
|
||||
inset && "pl-8",
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
inset,
|
||||
icon,
|
||||
variant = "default",
|
||||
children,
|
||||
asChild,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const iconSlot = (
|
||||
<span className="hidden size-4 shrink-0 items-center justify-center group-has-[[data-has-icon]]/menu:flex">
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
|
||||
const renderedChildren =
|
||||
asChild && React.isValidElement(children) ? (
|
||||
React.cloneElement(
|
||||
children as React.ReactElement<{ children?: React.ReactNode }>,
|
||||
{},
|
||||
iconSlot,
|
||||
(children as React.ReactElement<{ children?: React.ReactNode }>).props
|
||||
.children,
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{iconSlot}
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
asChild={asChild}
|
||||
data-has-icon={icon ? "" : undefined}
|
||||
className={cn(
|
||||
dropdownMenuItemVariants({ variant }),
|
||||
inset && "pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{renderedChildren}
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
},
|
||||
);
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
|
|
|
|||
|
|
@ -1,16 +1,51 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { FONT_OPTIONS, type FontFamily } from "@/constants/font-constants";
|
||||
useState,
|
||||
useMemo,
|
||||
useRef,
|
||||
useEffect,
|
||||
useCallback,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
import { List, type RowComponentProps } from "react-window";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
getCachedFontAtlas,
|
||||
loadFullFont,
|
||||
prefetchFontAtlas,
|
||||
clearFontAtlasCache,
|
||||
} from "@/lib/fonts/google-fonts";
|
||||
import type { FontAtlas, FontAtlasEntry } from "@/types/fonts";
|
||||
import { cn } from "@/utils/ui";
|
||||
import { ChevronDown, Search, Upload } from "lucide-react";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { TextFontIcon } from "@hugeicons/core-free-icons";
|
||||
|
||||
const FONT_TABS = [
|
||||
{ key: "all", label: "All fonts" },
|
||||
{ key: "favorites", label: "Favorites" },
|
||||
{ key: "my-fonts", label: "My fonts" },
|
||||
] as const;
|
||||
|
||||
type FontTab = (typeof FONT_TABS)[number]["key"];
|
||||
|
||||
const ROW_HEIGHT = 40;
|
||||
const SPRITE_ROW_HEIGHT = 40;
|
||||
const PREVIEW_SCALE = 0.8;
|
||||
const LIST_WIDTH = 288;
|
||||
const MAX_LIST_HEIGHT = 288;
|
||||
const OVERSCAN = 15;
|
||||
|
||||
interface FontPickerProps {
|
||||
defaultValue?: FontFamily;
|
||||
onValueChange?: (value: FontFamily) => void;
|
||||
defaultValue?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
|
@ -19,24 +54,256 @@ export function FontPicker({
|
|||
onValueChange,
|
||||
className,
|
||||
}: FontPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<FontTab>("all");
|
||||
const [atlas, setAtlas] = useState<FontAtlas | null>(() =>
|
||||
getCachedFontAtlas(),
|
||||
);
|
||||
const [status, setStatus] = useState<"idle" | "loading" | "error">(() =>
|
||||
getCachedFontAtlas() ? "idle" : "loading",
|
||||
);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const fontNames = useMemo(() => {
|
||||
if (!atlas) return [];
|
||||
return Object.keys(atlas.fonts).sort();
|
||||
}, [atlas]);
|
||||
|
||||
const filteredFonts = useMemo(() => {
|
||||
if (!search) return fontNames;
|
||||
const query = search.toLowerCase();
|
||||
return fontNames.filter((name) => name.toLowerCase().includes(query));
|
||||
}, [fontNames, search]);
|
||||
|
||||
const listHeight = Math.min(
|
||||
MAX_LIST_HEIGHT,
|
||||
filteredFonts.length * ROW_HEIGHT,
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async ({ family }: { family: string }) => {
|
||||
try {
|
||||
await loadFullFont({ family });
|
||||
onValueChange?.(family);
|
||||
} catch {
|
||||
onValueChange?.(family);
|
||||
}
|
||||
setOpen(false);
|
||||
},
|
||||
[onValueChange],
|
||||
);
|
||||
|
||||
// Load atlas on first open if cache is empty (fallback when prefetch hasn't completed)
|
||||
useEffect(() => {
|
||||
if (!open || atlas) return;
|
||||
|
||||
setStatus("loading");
|
||||
prefetchFontAtlas().then((data) => {
|
||||
if (data) {
|
||||
setAtlas(data);
|
||||
setStatus("idle");
|
||||
} else {
|
||||
setStatus("error");
|
||||
}
|
||||
});
|
||||
}, [open, atlas]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setSearch("");
|
||||
setActiveTab("all");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
clearFontAtlasCache();
|
||||
setStatus("loading");
|
||||
prefetchFontAtlas().then((data) => {
|
||||
if (data) {
|
||||
setAtlas(data);
|
||||
setStatus("idle");
|
||||
} else {
|
||||
setStatus("error");
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Select defaultValue={defaultValue} onValueChange={onValueChange}>
|
||||
<SelectTrigger
|
||||
className={cn("w-full", className)}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
"border-border bg-accent flex h-7 w-full cursor-pointer items-center justify-between gap-1 rounded-md border px-2.5 text-sm whitespace-nowrap focus-visible:border-primary focus-visible:ring-0 focus:outline-hidden",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<SelectValue placeholder="Select a font" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FONT_OPTIONS.map((font) => (
|
||||
<SelectItem
|
||||
key={font.value}
|
||||
value={font.value}
|
||||
style={{ fontFamily: font.value }}
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span className="text-muted-foreground [&_svg]:size-3.5 shrink-0">
|
||||
<HugeiconsIcon icon={TextFontIcon} />
|
||||
</span>
|
||||
<span className="truncate" style={{ fontFamily: defaultValue }}>
|
||||
{defaultValue ?? "Select a font"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className="size-3 shrink-0 opacity-50" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-72 p-0 overflow-hidden"
|
||||
align="start"
|
||||
side="left"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<div className="relative px-3 py-1.5">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-3.5 shrink-0 opacity-50" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder="Search fonts..."
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
size="xs"
|
||||
className="w-full pl-5 bg-transparent !border-none !shadow-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex border-b px-3">
|
||||
{FONT_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-xs border-b-2 -mb-px",
|
||||
activeTab === tab.key
|
||||
? "border-foreground text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{status === "loading" && (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
Loading fonts...
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<div className="flex flex-col items-center gap-3 py-8 px-4">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Failed to load font previews.
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={handleRetry}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{status === "idle" &&
|
||||
fontNames.length > 0 &&
|
||||
filteredFonts.length === 0 && (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No fonts found.
|
||||
</div>
|
||||
)}
|
||||
{status === "idle" && atlas && filteredFonts.length > 0 && (
|
||||
<List
|
||||
rowCount={filteredFonts.length}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
overscanCount={OVERSCAN}
|
||||
rowComponent={FontRow}
|
||||
rowProps={{
|
||||
atlas,
|
||||
filteredFonts,
|
||||
selectedFont: defaultValue,
|
||||
onFontSelect: handleSelect,
|
||||
}}
|
||||
style={{ height: listHeight, width: LIST_WIDTH }}
|
||||
/>
|
||||
)}
|
||||
<div className="border-t p-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-muted-foreground h-8 font-normal"
|
||||
onClick={() => {
|
||||
// TODO: Implement local font loading
|
||||
console.log("Load local fonts clicked");
|
||||
}}
|
||||
>
|
||||
{font.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Upload className="!size-3.5" />
|
||||
Load local fonts
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function FontSpritePreview({ entry }: { entry: FontAtlasEntry }) {
|
||||
return (
|
||||
<div
|
||||
className="shrink-0"
|
||||
style={{
|
||||
width: entry.w,
|
||||
height: SPRITE_ROW_HEIGHT,
|
||||
backgroundColor: "currentColor",
|
||||
WebkitMaskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`,
|
||||
WebkitMaskPosition: `-${entry.x}px -${entry.y}px`,
|
||||
WebkitMaskRepeat: "no-repeat",
|
||||
maskImage: `url(/fonts/font-chunk-${entry.ch}.avif)`,
|
||||
maskPosition: `-${entry.x}px -${entry.y}px`,
|
||||
maskRepeat: "no-repeat",
|
||||
transform: `scale(${PREVIEW_SCALE})`,
|
||||
transformOrigin: "left center",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type FontRowProps = {
|
||||
atlas: FontAtlas;
|
||||
filteredFonts: string[];
|
||||
selectedFont: string | undefined;
|
||||
onFontSelect: (params: { family: string }) => void;
|
||||
};
|
||||
|
||||
function FontRow({
|
||||
index,
|
||||
style,
|
||||
atlas,
|
||||
filteredFonts,
|
||||
selectedFont,
|
||||
onFontSelect,
|
||||
}: RowComponentProps<FontRowProps>) {
|
||||
const fontName = filteredFonts[index];
|
||||
const entry = atlas.fonts[fontName];
|
||||
const isSelected = fontName === selectedFont;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
style={style as CSSProperties}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2 px-3 outline-hidden hover:bg-popover-hover",
|
||||
isSelected && "bg-popover-hover",
|
||||
)}
|
||||
onClick={() => onFontSelect({ family: fontName })}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onFontSelect({ family: fontName });
|
||||
}
|
||||
}}
|
||||
aria-label={fontName}
|
||||
>
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
<FontSpritePreview entry={entry} />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,75 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { OTPInput, OTPInputContext } from "input-otp";
|
||||
import { Minus } from "lucide-react";
|
||||
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const InputOTP = React.forwardRef<
|
||||
React.ElementRef<typeof OTPInput>,
|
||||
React.ComponentPropsWithoutRef<typeof OTPInput>
|
||||
>(({ className, containerClassName, ...props }, ref) => (
|
||||
<OTPInput
|
||||
ref={ref}
|
||||
containerClassName={cn(
|
||||
"flex items-center justify-between gap-2 w-full has-disabled:opacity-50",
|
||||
containerClassName,
|
||||
)}
|
||||
className={cn("disabled:cursor-not-allowed", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
InputOTP.displayName = "InputOTP";
|
||||
|
||||
const InputOTPGroup = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-1 items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
InputOTPGroup.displayName = "InputOTPGroup";
|
||||
|
||||
const InputOTPSlot = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div"> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
const inputOTPContext = React.useContext(OTPInputContext);
|
||||
const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-input relative flex aspect-square min-w-[36px] flex-1 items-center justify-center border text-lg shadow-xs first:rounded-l-md first:border-l last:rounded-r-md",
|
||||
isActive && "ring-ring z-10 ring-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{char}
|
||||
{hasFakeCaret && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
InputOTPSlot.displayName = "InputOTPSlot";
|
||||
|
||||
const InputOTPSeparator = React.forwardRef<
|
||||
React.ElementRef<"div">,
|
||||
React.ComponentPropsWithoutRef<"div">
|
||||
>(({ ...props }, ref) => (
|
||||
<div ref={ref} role="separator" {...props}>
|
||||
<Minus />
|
||||
</div>
|
||||
));
|
||||
InputOTPSeparator.displayName = "InputOTPSeparator";
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||
|
|
@ -1,88 +1,85 @@
|
|||
"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-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-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-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-accent w-full pl-9"
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,17 +8,18 @@ import { forwardRef, type ComponentProps } from "react";
|
|||
import { useState } from "react";
|
||||
|
||||
const inputVariants = cva(
|
||||
"file:text-foreground placeholder:text-muted-foreground border-border bg-background flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-4 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"file:text-foreground placeholder:text-muted-foreground border-border bg-input flex w-full min-w-0 rounded-md border shadow-xs outline-none file:inline-flex file:border-0 file:bg-transparent file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:ring-0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "selection:bg-primary selection:text-primary-foreground focus-visible:border-primary focus-visible:ring-primary/10",
|
||||
destructive: "selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10",
|
||||
default: "selection:bg-primary selection:text-primary-foreground",
|
||||
destructive:
|
||||
"selection:bg-destructive selection:text-destructive-foreground focus-visible:border-destructive focus-visible:ring-destructive/10",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm",
|
||||
sm: "h-8 px-3 text-xs file:h-6 file:text-xs",
|
||||
default: "h-9 px-3 py-1 text-base file:h-7 file:text-sm md:text-sm",
|
||||
xs: "h-7 px-3 text-xs file:h-6 file:text-xs",
|
||||
sm: "h-8 px-3 text-sm file:h-6 file:text-xs",
|
||||
lg: "h-10 px-4 text-base file:h-8 file:text-sm md:text-sm",
|
||||
},
|
||||
},
|
||||
|
|
@ -102,7 +103,6 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||
/>
|
||||
{showClear && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="icon"
|
||||
onMouseDown={(e) => {
|
||||
|
|
@ -117,7 +117,6 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
|||
)}
|
||||
{showPasswordToggle && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
size="icon"
|
||||
onClick={() => onShowPasswordChange?.(!showPassword)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
"use client";
|
||||
|
||||
import { cn } from "@/utils/ui";
|
||||
import { useRef, useState, type ComponentProps } from "react";
|
||||
import { useFocusLock } from "@/hooks/use-focus-lock";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ArrowTurnBackwardIcon } from "@hugeicons/core-free-icons";
|
||||
|
||||
const DRAG_SENSITIVITIES = {
|
||||
default: 1,
|
||||
slow: 0.5,
|
||||
} as const;
|
||||
|
||||
type DragSensitivity = "default" | "slow";
|
||||
|
||||
interface NumberFieldProps
|
||||
extends Omit<ComponentProps<"input">, "size" | "type"> {
|
||||
icon?: React.ReactNode;
|
||||
dragSensitivity?: DragSensitivity;
|
||||
onScrub?: (value: number) => void;
|
||||
onScrubEnd?: () => void;
|
||||
allowExpressions?: boolean;
|
||||
onReset?: () => void;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
function NumberField({
|
||||
className,
|
||||
icon,
|
||||
disabled,
|
||||
dragSensitivity = "default",
|
||||
onScrub,
|
||||
onScrubEnd,
|
||||
value,
|
||||
allowExpressions = true,
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onMouseDown,
|
||||
onReset,
|
||||
isDefault = false,
|
||||
ref,
|
||||
...props
|
||||
}: NumberFieldProps & { ref?: React.Ref<HTMLInputElement> }) {
|
||||
const iconRef = useRef<HTMLSpanElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const startValueRef = useRef(0);
|
||||
const cumulativeDeltaRef = useRef(0);
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
|
||||
const { containerRef: wrapperRef } = useFocusLock<HTMLDivElement>({
|
||||
isActive: isInputFocused,
|
||||
onDismiss: () => inputRef.current?.blur(),
|
||||
cursor: "text",
|
||||
allowSelector: "input, textarea, [contenteditable]",
|
||||
});
|
||||
|
||||
const handleIconPointerDown = (event: React.PointerEvent) => {
|
||||
if (!onScrub || disabled || event.button !== 0) return;
|
||||
const parsed = parseFloat(String(value ?? "0"));
|
||||
startValueRef.current = Number.isNaN(parsed) ? 0 : parsed;
|
||||
cumulativeDeltaRef.current = 0;
|
||||
let hasReceivedFirstMove = false;
|
||||
iconRef.current?.requestPointerLock();
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
// first movementX after pointer lock often contains a bogus warp delta
|
||||
if (!hasReceivedFirstMove) {
|
||||
hasReceivedFirstMove = true;
|
||||
return;
|
||||
}
|
||||
cumulativeDeltaRef.current += moveEvent.movementX;
|
||||
const newValue =
|
||||
startValueRef.current +
|
||||
cumulativeDeltaRef.current * DRAG_SENSITIVITIES[dragSensitivity];
|
||||
onScrub(newValue);
|
||||
};
|
||||
|
||||
const handlePointerUp = () => {
|
||||
document.removeEventListener("pointermove", handlePointerMove);
|
||||
document.removeEventListener("pointerup", handlePointerUp);
|
||||
document.exitPointerLock();
|
||||
onScrubEnd?.();
|
||||
};
|
||||
|
||||
document.addEventListener("pointermove", handlePointerMove);
|
||||
document.addEventListener("pointerup", handlePointerUp);
|
||||
};
|
||||
|
||||
const canScrub = Boolean(icon && onScrub);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={cn(
|
||||
"border-border bg-accent flex h-7 w-full min-w-0 items-center rounded-md border text-sm outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-within:border-primary focus-within:ring-0 focus-within:ring-primary/10 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40",
|
||||
disabled && "pointer-events-none cursor-not-allowed opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{icon && (
|
||||
<span
|
||||
ref={iconRef}
|
||||
className={cn(
|
||||
"text-muted-foreground [&_svg]:!size-3.5 shrink-0 select-none pl-2.5 text-sm leading-none",
|
||||
canScrub && "cursor-ew-resize",
|
||||
)}
|
||||
onPointerDown={canScrub ? handleIconPointerDown : undefined}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<input
|
||||
type={allowExpressions ? "text" : "number"}
|
||||
inputMode={allowExpressions ? "decimal" : undefined}
|
||||
ref={inputRef}
|
||||
disabled={disabled}
|
||||
value={value}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 text-sm leading-none bg-transparent outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
|
||||
icon ? "px-1.5" : "pl-2.5",
|
||||
onReset ? "pr-0" : "pr-2.5",
|
||||
)}
|
||||
onMouseDown={(event) => {
|
||||
const inputElement = event.currentTarget;
|
||||
const shouldPreventNativeCaretPlacement =
|
||||
event.button === 0 && document.activeElement !== inputElement;
|
||||
if (shouldPreventNativeCaretPlacement) {
|
||||
event.preventDefault();
|
||||
inputElement.focus();
|
||||
inputElement.select();
|
||||
}
|
||||
onMouseDown?.(event);
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
setIsInputFocused(true);
|
||||
event.currentTarget.select();
|
||||
onFocus?.(event);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
const shouldBlurInput =
|
||||
event.key === "Enter" || event.key === "Escape";
|
||||
if (shouldBlurInput) event.currentTarget.blur();
|
||||
onKeyDown?.(event);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
setIsInputFocused(false);
|
||||
onBlur?.(event);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
{onReset && !isDefault && (
|
||||
<div className="shrink-0 pr-2 flex items-center">
|
||||
<Button
|
||||
variant="text"
|
||||
size="text"
|
||||
aria-label="Reset to default"
|
||||
onClick={onReset}
|
||||
>
|
||||
<HugeiconsIcon icon={ArrowTurnBackwardIcon} className="!size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { NumberField };
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import * as React from "react";
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
|
||||
import { cn } from "@/utils/ui";
|
||||
import { type ButtonProps, buttonVariants } from "./button";
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
Pagination.displayName = "Pagination";
|
||||
|
||||
const PaginationContent = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
PaginationContent.displayName = "PaginationContent";
|
||||
|
||||
const PaginationItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn("", className)} {...props} />
|
||||
));
|
||||
PaginationItem.displayName = "PaginationItem";
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean;
|
||||
} & Pick<ButtonProps, "size"> &
|
||||
React.ComponentProps<"a">;
|
||||
|
||||
const PaginationLink = ({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "text",
|
||||
size,
|
||||
}),
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
PaginationLink.displayName = "PaginationLink";
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationPrevious.displayName = "PaginationPrevious";
|
||||
|
||||
const PaginationNext = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="size-4" />
|
||||
</PaginationLink>
|
||||
);
|
||||
PaginationNext.displayName = "PaginationNext";
|
||||
|
||||
const PaginationEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
);
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis";
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationLink,
|
||||
PaginationItem,
|
||||
PaginationPrevious,
|
||||
PaginationNext,
|
||||
PaginationEllipsis,
|
||||
};
|
||||
|
|
@ -1,167 +0,0 @@
|
|||
import * as React from "react";
|
||||
import { CheckIcon, ChevronsUpDown } from "lucide-react";
|
||||
import * as RPNInput from "react-phone-number-input";
|
||||
import flags from "react-phone-number-input/flags";
|
||||
|
||||
import { Button } from "./button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "./command";
|
||||
import { Input } from "./input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./popover";
|
||||
import { ScrollArea } from "./scroll-area";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
type PhoneInputProps = Omit<
|
||||
React.ComponentProps<"input">,
|
||||
"onChange" | "value" | "ref"
|
||||
> &
|
||||
Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & {
|
||||
onChange?: (value: RPNInput.Value) => void;
|
||||
};
|
||||
|
||||
const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> =
|
||||
React.forwardRef<React.ElementRef<typeof RPNInput.default>, PhoneInputProps>(
|
||||
({ className, onChange, defaultCountry = "US", ...props }, ref) => {
|
||||
return (
|
||||
<RPNInput.default
|
||||
ref={ref}
|
||||
className={cn("flex", className)}
|
||||
flagComponent={FlagComponent}
|
||||
countrySelectComponent={CountrySelect}
|
||||
inputComponent={InputComponent}
|
||||
defaultCountry={defaultCountry}
|
||||
international
|
||||
addInternationalOption
|
||||
smartCaret={false}
|
||||
/**
|
||||
* Handles the onChange event.
|
||||
*
|
||||
* react-phone-number-input might trigger the onChange event as undefined
|
||||
* when a valid phone number is not entered. To prevent this,
|
||||
* the value is coerced to an empty string.
|
||||
*
|
||||
* @param {E164Number | undefined} value - The entered value
|
||||
*/
|
||||
onChange={(value) => onChange?.(value || ("" as RPNInput.Value))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
PhoneInput.displayName = "PhoneInput";
|
||||
|
||||
const InputComponent = React.forwardRef<
|
||||
HTMLInputElement,
|
||||
React.ComponentProps<"input">
|
||||
>(({ className, size: _size, ...props }, ref) => (
|
||||
<Input
|
||||
className={cn("rounded-s-none rounded-e-lg", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
InputComponent.displayName = "InputComponent";
|
||||
|
||||
type CountryEntry = { label: string; value: RPNInput.Country | undefined };
|
||||
|
||||
type CountrySelectProps = {
|
||||
disabled?: boolean;
|
||||
value: RPNInput.Country;
|
||||
options: CountryEntry[];
|
||||
onChange: (country: RPNInput.Country) => void;
|
||||
};
|
||||
|
||||
const CountrySelect = ({
|
||||
disabled,
|
||||
value: selectedCountry,
|
||||
options: countryList,
|
||||
onChange,
|
||||
}: CountrySelectProps) => {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="flex gap-1 rounded-s-lg rounded-e-none border-r-0 px-3 focus:z-10"
|
||||
disabled={disabled}
|
||||
>
|
||||
<FlagComponent
|
||||
country={selectedCountry}
|
||||
countryName={selectedCountry}
|
||||
/>
|
||||
<ChevronsUpDown
|
||||
className={cn(
|
||||
"-mr-2 opacity-50",
|
||||
disabled ? "hidden" : "opacity-100",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search country..." />
|
||||
<CommandList>
|
||||
<ScrollArea className="h-72">
|
||||
<CommandEmpty>No country found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{countryList.map(({ value, label }) =>
|
||||
value ? (
|
||||
<CountrySelectOption
|
||||
key={value}
|
||||
country={value}
|
||||
countryName={label}
|
||||
selectedCountry={selectedCountry}
|
||||
onChange={onChange}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
interface CountrySelectOptionProps extends RPNInput.FlagProps {
|
||||
selectedCountry: RPNInput.Country;
|
||||
onChange: (country: RPNInput.Country) => void;
|
||||
}
|
||||
|
||||
const CountrySelectOption = ({
|
||||
country,
|
||||
countryName,
|
||||
selectedCountry,
|
||||
onChange,
|
||||
}: CountrySelectOptionProps) => {
|
||||
return (
|
||||
<CommandItem className="gap-2" onSelect={() => onChange(country)}>
|
||||
<FlagComponent country={country} countryName={countryName} />
|
||||
<span className="flex-1 text-sm">{countryName}</span>
|
||||
<span className="text-foreground/50 text-sm">{`+${RPNInput.getCountryCallingCode(country)}`}</span>
|
||||
<CheckIcon
|
||||
className={`ml-auto size-4 ${country === selectedCountry ? "opacity-100" : "opacity-0"}`}
|
||||
/>
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
|
||||
const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => {
|
||||
const Flag = flags[country];
|
||||
|
||||
return (
|
||||
<span className="bg-foreground/20 flex h-4 w-6 overflow-hidden rounded-sm [&_svg]:size-full">
|
||||
{Flag && <Flag title={countryName} />}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export { PhoneInput };
|
||||
|
|
@ -10,6 +10,8 @@ const PopoverTrigger = PopoverPrimitive.Trigger;
|
|||
|
||||
const PopoverAnchor = PopoverPrimitive.Anchor;
|
||||
|
||||
const PopoverClose = PopoverPrimitive.Close;
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
|
|
@ -20,7 +22,7 @@ const PopoverContent = React.forwardRef<
|
|||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-[0_0_10px_rgba(0,0,0,0.15)] outline-hidden",
|
||||
"bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-[0_0_10px_rgba(0,0,0,0.15)] outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -29,4 +31,4 @@ const PopoverContent = React.forwardRef<
|
|||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor, PopoverClose };
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import { cn } from "@/utils/ui";
|
||||
import rehypeParse from "rehype-parse";
|
||||
import { unified } from "unified";
|
||||
import { createElement } from "react";
|
||||
import type React from "react";
|
||||
|
||||
type ProseProps = React.HTMLAttributes<HTMLElement> & {
|
||||
|
|
@ -6,6 +9,92 @@ type ProseProps = React.HTMLAttributes<HTMLElement> & {
|
|||
html: string;
|
||||
};
|
||||
|
||||
type HastTextNode = {
|
||||
type: "text";
|
||||
value: string;
|
||||
};
|
||||
|
||||
type HastElementNode = {
|
||||
type: "element";
|
||||
tagName: string;
|
||||
properties?: Record<string, unknown>;
|
||||
children?: HastNode[];
|
||||
};
|
||||
|
||||
type HastRootNode = {
|
||||
type: "root";
|
||||
children?: HastNode[];
|
||||
};
|
||||
|
||||
type HastNode = HastTextNode | HastElementNode | HastRootNode;
|
||||
|
||||
function toReactProps({
|
||||
properties,
|
||||
}: {
|
||||
properties: HastElementNode["properties"];
|
||||
}): Record<string, unknown> {
|
||||
if (!properties) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const props: Record<string, unknown> = {};
|
||||
|
||||
for (const [propertyName, propertyValue] of Object.entries(properties)) {
|
||||
if (propertyValue === null || propertyValue === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (propertyName.startsWith("on")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (propertyName === "class") {
|
||||
props.className = Array.isArray(propertyValue)
|
||||
? propertyValue.join(" ")
|
||||
: propertyValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (propertyName === "for") {
|
||||
props.htmlFor = propertyValue;
|
||||
continue;
|
||||
}
|
||||
|
||||
props[propertyName] = propertyValue;
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
|
||||
function renderHastNode({
|
||||
node,
|
||||
key,
|
||||
}: {
|
||||
node: HastNode;
|
||||
key: string;
|
||||
}): React.ReactNode {
|
||||
if (node.type === "text") {
|
||||
return node.value;
|
||||
}
|
||||
|
||||
if (node.type !== "element") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const children = (node.children ?? []).map((childNode, index) =>
|
||||
renderHastNode({ node: childNode, key: `${key}-${index}` }),
|
||||
);
|
||||
|
||||
return createElement(node.tagName, { ...toReactProps({ properties: node.properties }), key }, children);
|
||||
}
|
||||
|
||||
function renderHtmlNodes({ html }: { html: string }): React.ReactNode {
|
||||
const rootNode = unified().use(rehypeParse, { fragment: true }).parse(html) as HastRootNode;
|
||||
return (rootNode.children ?? []).map((childNode, index) =>
|
||||
renderHastNode({ node: childNode, key: `prose-node-${index}` }),
|
||||
);
|
||||
}
|
||||
|
||||
function Prose({ children, html, className }: ProseProps) {
|
||||
return (
|
||||
<article
|
||||
|
|
@ -14,7 +103,7 @@ function Prose({ children, html, className }: ProseProps) {
|
|||
className,
|
||||
)}
|
||||
>
|
||||
{html ? <div dangerouslySetInnerHTML={{ __html: html }} /> : children}
|
||||
{html ? renderHtmlNodes({ html }) : children}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@
|
|||
|
||||
import * as React from "react";
|
||||
import { Select as SelectPrimitive } from "radix-ui";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Check } from "lucide-react";
|
||||
import { ArrowUpIcon, ArrowDownIcon } from "@hugeicons/core-free-icons";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/utils/ui";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
|
|
@ -14,12 +15,13 @@ const SelectGroup = SelectPrimitive.Group;
|
|||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const selectItemVariants = cva(
|
||||
"relative flex cursor-pointer select-none items-center gap-2 rounded-xl px-2.5 py-2 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"relative flex cursor-pointer select-none items-center gap-1.5 rounded-sm px-2 py-1 text-sm text-foreground/85 outline-hidden data-[highlighted]:bg-popover-hover data-disabled:pointer-events-none data-disabled:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "",
|
||||
destructive: "text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive",
|
||||
destructive:
|
||||
"text-destructive data-[highlighted]:bg-destructive/5 data-[highlighted]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
@ -28,22 +30,48 @@ const selectItemVariants = cva(
|
|||
},
|
||||
);
|
||||
|
||||
const selectTriggerVariants = cva(
|
||||
"border-border ring-offset-background placeholder:text-muted-foreground flex h-7 w-auto cursor-pointer items-center justify-between gap-1 rounded-md border px-2.5 text-sm whitespace-nowrap transition-none focus:border-primary focus:ring-0 focus:ring-primary/10 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-accent",
|
||||
outline: "bg-background hover:bg-accent/50",
|
||||
},
|
||||
size: {
|
||||
default: "",
|
||||
sm: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> &
|
||||
VariantProps<typeof selectTriggerVariants> & {
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
>(({ className, children, icon, variant, size, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-accent ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-8 w-auto cursor-pointer items-center justify-between gap-1 rounded-md px-3 py-2 text-sm whitespace-nowrap focus:ring-1 focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"focus:border-primary focus:ring-4 focus:ring-primary/10 border-transparent transition-none",
|
||||
className,
|
||||
)}
|
||||
className={cn(selectTriggerVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<div className="flex items-center gap-1.5">
|
||||
{icon && (
|
||||
<span className="text-muted-foreground [&_svg]:size-3.5 shrink-0">
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="size-3 opacity-50" />
|
||||
<HugeiconsIcon icon={ArrowDownIcon} className="size-4" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
|
|
@ -61,7 +89,7 @@ const SelectScrollUpButton = React.forwardRef<
|
|||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
<HugeiconsIcon icon={ArrowUpIcon} className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
|
@ -78,7 +106,7 @@ const SelectScrollDownButton = React.forwardRef<
|
|||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
<HugeiconsIcon icon={ArrowDownIcon} className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName =
|
||||
|
|
@ -92,7 +120,7 @@ const SelectContent = React.forwardRef<
|
|||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground z-50 max-h-96 min-w-32 overflow-hidden rounded-2xl border p-2 shadow-lg",
|
||||
"bg-popover text-popover-foreground z-50 max-h-(--radix-select-content-available-height) min-w-32 overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
|
|
@ -124,7 +152,7 @@ const SelectLabel = React.forwardRef<
|
|||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-3 pb-2 pt-1 text-[11px] font-bold uppercase tracking-wider text-muted-foreground",
|
||||
"px-2 pb-1 pt-0.5 text-[11px] font-bold uppercase tracking-wider text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -140,12 +168,12 @@ const SelectItem = React.forwardRef<
|
|||
>(({ className, children, variant = "default", ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(selectItemVariants({ variant }), "pr-8 pl-2", className)}
|
||||
className={cn(selectItemVariants({ variant }), "pl-6 pr-2", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<span className="absolute left-1.5 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="size-4" />
|
||||
<Check className="size-3.5" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
|
|
@ -159,7 +187,7 @@ const SelectSeparator = React.forwardRef<
|
|||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("bg-border mx-1 my-2 h-px", className)}
|
||||
className={cn("bg-border mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
|
|
|||
|
|
@ -1,725 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { PanelLeftIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { cn } from "@/utils/ui";
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||
const SIDEBAR_WIDTH = "16rem";
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem";
|
||||
const SIDEBAR_WIDTH_ICON = "3rem";
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
|
||||
|
||||
type SidebarContextProps = {
|
||||
state: "expanded" | "collapsed";
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
openMobile: boolean;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
isMobile: boolean;
|
||||
toggleSidebar: () => void;
|
||||
};
|
||||
|
||||
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
|
||||
|
||||
function useSidebar() {
|
||||
const context = React.useContext(SidebarContext);
|
||||
if (!context) {
|
||||
throw new Error("useSidebar must be used within a SidebarProvider.");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
onOpenChange: setOpenProp,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const [openMobile, setOpenMobile] = React.useState(false);
|
||||
|
||||
// This is the internal state of the sidebar.
|
||||
// We use openProp and setOpenProp for control from outside the component.
|
||||
const [_open, _setOpen] = React.useState(defaultOpen);
|
||||
const open = openProp ?? _open;
|
||||
const setOpen = React.useCallback(
|
||||
(value: boolean | ((value: boolean) => boolean)) => {
|
||||
const openState = typeof value === "function" ? value(open) : value;
|
||||
if (setOpenProp) {
|
||||
setOpenProp(openState);
|
||||
} else {
|
||||
_setOpen(openState);
|
||||
}
|
||||
|
||||
// This sets the cookie to keep the sidebar state.
|
||||
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
|
||||
},
|
||||
[setOpenProp, open],
|
||||
);
|
||||
|
||||
// Helper to toggle the sidebar.
|
||||
const toggleSidebar = React.useCallback(() => {
|
||||
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
|
||||
}, [isMobile, setOpen, setOpenMobile]);
|
||||
|
||||
// Adds a keyboard shortcut to toggle the sidebar.
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (
|
||||
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
|
||||
(event.metaKey || event.ctrlKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
toggleSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [toggleSidebar]);
|
||||
|
||||
// We add a state so that we can do data-state="expanded" or "collapsed".
|
||||
// This makes it easier to style the sidebar with Tailwind classes.
|
||||
const state = open ? "expanded" : "collapsed";
|
||||
|
||||
const contextValue = React.useMemo<SidebarContextProps>(
|
||||
() => ({
|
||||
state,
|
||||
open,
|
||||
setOpen,
|
||||
isMobile,
|
||||
openMobile,
|
||||
setOpenMobile,
|
||||
toggleSidebar,
|
||||
}),
|
||||
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarContext.Provider value={contextValue}>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<div
|
||||
data-slot="sidebar-wrapper"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH,
|
||||
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</SidebarContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({
|
||||
side = "left",
|
||||
variant = "sidebar",
|
||||
collapsible = "offcanvas",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
side?: "left" | "right";
|
||||
variant?: "sidebar" | "floating" | "inset";
|
||||
collapsible?: "offcanvas" | "icon" | "none";
|
||||
}) {
|
||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
||||
|
||||
if (collapsible === "none") {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar"
|
||||
className={cn(
|
||||
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||
<SheetContent
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
|
||||
style={
|
||||
{
|
||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex size-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group peer text-sidebar-foreground hidden md:block"
|
||||
data-state={state}
|
||||
data-collapsible={state === "collapsed" ? collapsible : ""}
|
||||
data-variant={variant}
|
||||
data-side={side}
|
||||
data-slot="sidebar"
|
||||
>
|
||||
{/* This is what handles the sidebar gap on desktop */}
|
||||
<div
|
||||
data-slot="sidebar-gap"
|
||||
className={cn(
|
||||
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
|
||||
"group-data-[collapsible=offcanvas]:w-0",
|
||||
"group-data-[side=right]:rotate-180",
|
||||
variant === "floating" || variant === "inset"
|
||||
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
// Adjust the padding for floating and inset variants.
|
||||
variant === "floating" || variant === "inset"
|
||||
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
|
||||
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
data-sidebar="sidebar"
|
||||
data-slot="sidebar-inner"
|
||||
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex size-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarTrigger({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
data-slot="sidebar-trigger"
|
||||
variant="text"
|
||||
size="icon"
|
||||
className={cn("size-7", className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
toggleSidebar();
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
className={cn(
|
||||
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
|
||||
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
|
||||
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
|
||||
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
return (
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Input>) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="sidebar-input"
|
||||
data-sidebar="input"
|
||||
className={cn("bg-background h-8 w-full shadow-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-header"
|
||||
data-sidebar="header"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-footer"
|
||||
data-sidebar="footer"
|
||||
className={cn("flex flex-col gap-2 p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="sidebar-separator"
|
||||
data-sidebar="separator"
|
||||
className={cn("bg-sidebar-border mx-2 w-auto", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-content"
|
||||
data-sidebar="content"
|
||||
className={cn(
|
||||
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group"
|
||||
data-sidebar="group"
|
||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-group-content"
|
||||
data-sidebar="group-content"
|
||||
className={cn("w-full text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu"
|
||||
data-sidebar="menu"
|
||||
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-item"
|
||||
data-sidebar="menu-item"
|
||||
className={cn("group/menu-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sidebarMenuButtonVariants = cva(
|
||||
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
outline:
|
||||
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 text-sm",
|
||||
sm: "h-7 text-xs",
|
||||
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-badge"
|
||||
data-sidebar="menu-badge"
|
||||
className={cn(
|
||||
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
|
||||
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSkeleton({
|
||||
className,
|
||||
showIcon = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showIcon?: boolean;
|
||||
}) {
|
||||
// Random width between 50 to 90%.
|
||||
const width = React.useMemo(() => {
|
||||
return `${Math.floor(Math.random() * 40) + 50}%`;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-slot="sidebar-menu-skeleton"
|
||||
data-sidebar="menu-skeleton"
|
||||
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{showIcon && (
|
||||
<Skeleton
|
||||
className="size-4 rounded-md"
|
||||
data-sidebar="menu-skeleton-icon"
|
||||
/>
|
||||
)}
|
||||
<Skeleton
|
||||
className="h-4 max-w-(--skeleton-width) flex-1"
|
||||
data-sidebar="menu-skeleton-text"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": width,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
|
||||
return (
|
||||
<ul
|
||||
data-slot="sidebar-menu-sub"
|
||||
data-sidebar="menu-sub"
|
||||
className={cn(
|
||||
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="sidebar-menu-sub-item"
|
||||
data-sidebar="menu-sub-item"
|
||||
className={cn("group/menu-sub-item relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarGroup,
|
||||
SidebarGroupAction,
|
||||
SidebarGroupContent,
|
||||
SidebarGroupLabel,
|
||||
SidebarHeader,
|
||||
SidebarInput,
|
||||
SidebarInset,
|
||||
SidebarMenu,
|
||||
SidebarMenuAction,
|
||||
SidebarMenuBadge,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarMenuSkeleton,
|
||||
SidebarMenuSub,
|
||||
SidebarMenuSubButton,
|
||||
SidebarMenuSubItem,
|
||||
SidebarProvider,
|
||||
SidebarRail,
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
};
|
||||
|
|
@ -3,15 +3,15 @@ import { Loading03Icon } from "@hugeicons/core-free-icons";
|
|||
import { cn } from "@/utils/ui";
|
||||
|
||||
function Spinner({ className, ...props }: Omit<HugeiconsIconProps, "icon">) {
|
||||
return (
|
||||
<HugeiconsIcon
|
||||
icon={Loading03Icon}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return (
|
||||
<HugeiconsIcon
|
||||
icon={Loading03Icon}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Spinner }
|
||||
export { Spinner };
|
||||
|
|
|
|||
|
|
@ -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-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-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-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-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 };
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ const Textarea = React.forwardRef<
|
|||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent/50 flex min-h-[60px] w-full rounded-md border px-3 py-2 resize-none text-base shadow-xs outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-primary focus-visible:ring-4 focus-visible:ring-primary/10",
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input bg-accent flex min-h-[60px] w-full rounded-md border px-3 py-2 resize-none text-base shadow-xs outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-primary focus-visible:ring-0 focus-visible:ring-primary/10",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ const TooltipContent = React.forwardRef<
|
|||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="absolute top-1/2 left-[-6px] -translate-y-1/2"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M6 0L0 5L6 10V0Z"
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue