diff --git a/.cursor/rules/codebase-index.mdc b/.cursor/rules/codebase-index.mdc new file mode 100644 index 000000000..be1509dc5 --- /dev/null +++ b/.cursor/rules/codebase-index.mdc @@ -0,0 +1,1837 @@ +--- +alwaysApply: true +--- + +# video-editor-oss Codebase Index + +**This file provides an index of exported functions, types, interfaces, classes, and constants in your codebase.** + +Updated in real-time by Twiggy. Use this to discover existing utilities and avoid duplicating code. + +## How to Use + +When implementing new features: +1. Check if similar functionality already exists +2. Reuse existing types and utilities +3. Understand the API surface of your codebase + +```typescript +## apps/web/src/constants + +editor-constants.ts + export const PLATFORM_LAYOUTS: Record + export const PANEL_CONFIG + +export-constants.ts + export const DEFAULT_EXPORT_OPTIONS + export const EXPORT_MIME_TYPES + +font-constants.ts + export interface FontOption { + value: string + label: string + category: "system" | "google" | "custom" + weights?: number[] + hasClassName?: boolean + } + export const FONT_OPTIONS: FontOption[] + export const DEFAULT_FONT + export type FontFamily = (typeof FONT_OPTIONS)[number]["value"] + export const getFontByValue = (value: string): FontOption | undefined => ... + export const getGoogleFonts = (): FontOption[] => ... + export const getSystemFonts = (): FontOption[] => ... + +language-constants.ts + export const LANGUAGES + +project-constants.ts + export const DEFAULT_CANVAS_PRESETS: TCanvasSize[] + export const FPS_PRESETS + export const BLUR_INTENSITY_PRESETS: { label: string; value: number }[] + export const DEFAULT_CANVAS_SIZE: TCanvasSize + export const DEFAULT_FPS + export const DEFAULT_BLUR_INTENSITY + export const DEFAULT_COLOR + +site-constants.ts + export const SITE_URL + export const SITE_INFO + export type ExternalTool = { + name: string; + description: string; + url: string; + icon: React.ElementType; + } + export const EXTERNAL_TOOLS: ExternalTool[] + export const DEFAULT_LOGO_URL + export const SOCIAL_LINKS + export type Sponsor = { + name: string; + url: string; + logo: string; + description: string; + } + export const SPONSORS: Sponsor[] + +stickers-constants.ts + export const STICKER_CATEGORIES + export const STICKER_CATEGORY_CONFIG: Record< + (typeof STICKER_CATEGORIES)[number], + string | undefined + > + +text-constants.ts + export const DEFAULT_TEXT_ELEMENT: Omit + +timeline-constants.tsx + export const TRACK_COLORS: Record + export const TRACK_HEIGHTS: Record + export const TRACK_GAP + export const TIMELINE_CONSTANTS + export const DEFAULT_TIMELINE_VIEW_STATE: TTimelineViewState + export const TRACK_ICONS: Record + +transcription-constants.ts + export const TRANSCRIPTION_LANGUAGES + export const TRANSCRIPTION_MODELS: TranscriptionModel[] + export const DEFAULT_TRANSCRIPTION_MODEL: TranscriptionModelId + export const DEFAULT_CHUNK_LENGTH_SECONDS + export const DEFAULT_STRIDE_SECONDS + export const DEFAULT_WORDS_PER_CAPTION + export const MIN_CAPTION_DURATION_SECONDS + +## apps/web/src/core + +index.ts + export class EditorCore { + instance: EditorCore | null + command: CommandManager + playback: PlaybackManager + timeline: TimelineManager + scenes: ScenesManager + project: ProjectManager + media: MediaManager + renderer: RendererManager + save: SaveManager + audio: AudioManager + selection: SelectionManager + static getInstance(): EditorCore + static reset(): void + } + +## apps/web/src/hooks + +use-editor.ts + export function useEditor(): EditorCore + +use-file-upload.ts + export function useFileUpload({ + accept, + multiple, + onFilesSelected, + }: UseFileUploadOptions = {}) + +use-infinite-scroll.ts + export function useInfiniteScroll({ + onLoadMore, + hasMore, + isLoading, + threshold = 200, + enabled = true, + }: UseInfiniteScrollOptions) + +use-keybindings.ts + export function useKeybindingsListener() + export function useKeybindingDisabler() + +use-keyboard-shortcuts-help.ts + export interface KeyboardShortcut { + id: string + keys: string[] + description: string + category: string + action: TAction + icon?: React.ReactNode + } + export function useKeyboardShortcutsHelp() + +use-mobile.ts + export function useIsMobile() + +use-raf-loop.ts + export function useRafLoop(callback: ({ time }: { time: number }) => void) + +use-reveal-item.ts + export function useRevealItem( + highlightId: string | null, + onClearHighlight: () => void, + highlightDuration = 1000, + ) + +use-sound-search.ts + export function useSoundSearch({ + query, + commercialOnly, + }: { + query: string; + commercialOnly: boolean; + }) + +## apps/web/src/hooks/actions + +use-action-handler.ts + export function useActionHandler( + action: A, + handler: TActionFunc, + isActive: TActionHandlerOptions, + ) + +use-editor-actions.ts + export function useEditorActions() + +## apps/web/src/hooks/storage + +use-local-storage.ts + export function useLocalStorage({ + key, + defaultValue, + }: { + key: string; + defaultValue: T; + }): [ + T, + ({ value }: { value: T | ((previousValue: T) => T) }) => void, + boolean, + ] + +## apps/web/src/hooks/timeline + +use-edge-auto-scroll.ts + export function useEdgeAutoScroll({ + isActive, + getMouseClientX, + rulerScrollRef, + tracksScrollRef, + contentWidth, + edgeThreshold = 100, + maxScrollSpeed = 15, + }: UseEdgeAutoScrollParams): void + +use-scroll-sync.ts + export function useScrollSync({ + tracksScrollRef, + rulerScrollRef, + trackLabelsScrollRef, + bookmarksScrollRef, + }: UseScrollSyncProps) + +use-selection-box.ts + export function useSelectionBox({ + containerRef, + onSelectionComplete, + isEnabled = true, + tracksScrollRef, + zoomLevel, + }: UseSelectionBoxProps) + +use-snap-indicator-position.ts + export function useSnapIndicatorPosition({ + snapPoint, + zoomLevel, + tracks, + timelineRef, + trackLabelsRef, + tracksScrollRef, + }: UseSnapIndicatorPositionParams): SnapIndicatorPosition + +use-timeline-drag-drop.ts + export function useTimelineDragDrop({ + containerRef, + headerRef, + zoomLevel, + }: UseTimelineDragDropProps) + +use-timeline-playhead.ts + export function useTimelinePlayhead({ + zoomLevel, + rulerRef, + rulerScrollRef, + tracksScrollRef, + playheadRef, + }: UseTimelinePlayheadProps) + +use-timeline-seek.ts + export function useTimelineSeek({ + playheadRef, + trackLabelsRef, + rulerScrollRef, + tracksScrollRef, + zoomLevel, + duration, + isSelecting, + clearSelectedElements, + seek, + }: UseTimelineSeekProps) + +use-timeline-snapping.ts + export interface SnapPoint { + time: number + type: "element-start" | "element-end" | "playhead" + elementId?: string + trackId?: string + } + export interface SnapResult { + snappedTime: number + snapPoint: SnapPoint | null + snapDistance: number + } + export interface UseTimelineSnappingOptions { + snapThreshold?: number + enableElementSnapping?: boolean + enablePlayheadSnapping?: boolean + } + export function useTimelineSnapping({ + snapThreshold = 10, + enableElementSnapping = true, + enablePlayheadSnapping = true, + }: UseTimelineSnappingOptions = {}) + +use-timeline-zoom.ts + export function useTimelineZoom({ + containerRef, + minZoom = TIMELINE_CONSTANTS.ZOOM_MIN, + initialZoom, + initialScrollLeft, + initialPlayheadTime, + tracksScrollRef, + rulerScrollRef, + }: UseTimelineZoomProps): UseTimelineZoomReturn + +## apps/web/src/hooks/timeline/element + +use-element-interaction.ts + export function useElementInteraction({ + zoomLevel, + timelineRef, + tracksContainerRef, + tracksScrollRef, + headerRef, + snappingEnabled, + onSnapPointChange, + }: UseElementInteractionProps) + +use-element-resize.ts + export interface ResizeState { + elementId: string + side: "left" | "right" + startX: number + initialTrimStart: number + initialTrimEnd: number + initialStartTime: number + initialDuration: number + } + export function useTimelineElementResize({ + element, + track, + zoomLevel, + onSnapPointChange, + onResizeStateChange, + }: UseTimelineElementResizeProps) + +use-element-selection.ts + export function useElementSelection() + +## apps/web/src/lib + +drag-data.ts + export function setDragData({ + dataTransfer, + dragData, + }: { + dataTransfer: DataTransfer; + dragData: TimelineDragData; + }): void + export function getDragData({ + dataTransfer, + }: { + dataTransfer: DataTransfer; + }): TimelineDragData | null + export function hasDragData({ + dataTransfer, + }: { + dataTransfer: DataTransfer; + }): boolean + export function clearDragData(): void + +export.ts + export function getExportMimeType({ + format, + }: { + format: ExportFormat; + }): string + export function getExportFileExtension({ + format, + }: { + format: ExportFormat; + }): string + +iconify-api.ts + export const ICONIFY_HOSTS + export interface IconSet { + prefix: string + name: string + total: number + author?: { + name: string; + url?: string; + } + license?: { + title: string; + spdx?: string; + url?: string; + } + samples?: string[] + category?: string + palette?: boolean + } + export interface IconSearchResult { + icons: string[] + total: number + limit: number + start: number + collections: Record + } + export interface CollectionInfo { + prefix: string + total: number + title?: string + uncategorized?: string[] + categories?: Record + hidden?: string[] + aliases?: Record + } + export function getCollections( + category?: string, + ): Promise> + export function getCollection( + prefix: string, + ): Promise + export function searchIcons( + query: string, + limit: number = 64, + prefixes?: string[], + category?: string, + ): Promise + export function buildIconSvgUrl( + host: string, + iconName: string, + params?: { + color?: string; + width?: number; + height?: number; + flip?: "horizontal" | "vertical" | "horizontal,vertical"; + rotate?: number | string; + }, + ): string + export function getIconSvgUrl( + iconName: string, + params?: Parameters[2], + ): string + export function downloadSvgAsText( + iconName: string, + params?: Parameters[1], + ): Promise + export function svgToFile(svgText: string, fileName: string): File + export const POPULAR_COLLECTIONS + export function getCategoriesFromCollections( + collections: Record, + ): string[] + +rate-limit.ts + export const baseRateLimit + export function checkRateLimit({ request }: { request: Request }) + +scenes.ts + export function getMainScene({ scenes }: { scenes: TScene[] }): TScene | null + export function ensureMainScene({ scenes }: { scenes: TScene[] }): TScene[] + export function buildDefaultScene({ + name, + isMain, + }: { + name: string; + isMain: boolean; + }): TScene + export function canDeleteScene({ scene }: { scene: TScene }): { + canDelete: boolean; + reason?: string; + } + export function getFallbackSceneAfterDelete({ + scenes, + deletedSceneId, + currentSceneId, + }: { + scenes: TScene[]; + deletedSceneId: string; + currentSceneId: string | null; + }): TScene | null + export function findCurrentScene({ + scenes, + currentSceneId, + }: { + scenes: TScene[]; + currentSceneId: string; + }): TScene | null + export function getProjectDurationFromScenes({ + scenes, + }: { + scenes: TScene[]; + }): number + export function updateSceneInArray({ + scenes, + sceneId, + updates, + }: { + scenes: TScene[]; + sceneId: string; + updates: Partial; + }): TScene[] + +time.ts + export function roundToFrame({ + time, + fps, + }: { + time: number; + fps: number; + }): number + export function formatTimeCode({ + timeInSeconds, + format = "HH:MM:SS:CS", + fps, + }: { + timeInSeconds: number; + format?: TTimeCode; + fps?: number; + }): string + export function parseTimeCode({ + timeCode, + format = "HH:MM:SS:CS", + fps, + }: { + timeCode: string; + format?: TTimeCode; + fps: number; + }): number | null + export function guessTimeCodeFormat({ + timeCode, + }: { + timeCode: string; + }): TTimeCode | null + export function timeToFrame({ + time, + fps, + }: { + time: number; + fps: number; + }): number + export function frameToTime({ + frame, + fps, + }: { + frame: number; + fps: number; + }): number + export function snapTimeToFrame({ + time, + fps, + }: { + time: number; + fps: number; + }): number + export function getSnappedSeekTime({ + rawTime, + duration, + fps, + }: { + rawTime: number; + duration: number; + fps: number; + }): number + export function getLastFrameTime({ + duration, + fps, + }: { + duration: number; + fps: number; + }): number + +## apps/web/src/lib/actions + +definitions.ts + export type TActionCategory = | "playback" + | "navigation" + | "editing" + | "selection" + | "history" + | "timeline" + | "controls" + export interface TActionDefinition { + description: string + category: TActionCategory + defaultShortcuts?: ShortcutKey[] + args?: Record + } + export const ACTIONS + export type TAction = keyof typeof ACTIONS + export function getActionDefinition(action: TAction): TActionDefinition + export function getDefaultShortcuts(): Record + +registry.ts + export function bindAction( + action: A, + handler: TActionFunc, + ) + export function unbindAction( + action: A, + handler: TActionFunc, + ) + export const invokeAction = ( + action: A, + args?: TArgOfAction, + trigger?: TInvocationTrigger, + ) => ... + +types.ts + export type TActionArgsMap = { + "seek-forward": { seconds: number } | undefined; + "seek-backward": { seconds: number } | undef... + export type TActionWithArgs = keyof TActionArgsMap + export type TActionWithOptionalArgs = | TActionWithNoArgs + | TKeysWithValueUndefined + export type TActionWithNoArgs = Exclude + export type TArgOfAction = A extends TActionWithArgs + ? TActionArgsMap[A] + : undefined + export type TActionFunc = A extends TActionWithArgs + ? (arg: TArgOfAction, trigger?: TInvocationTrigger) => void + : (_?:... + export type TInvocationTrigger = "keypress" | "mouseclick" + export type TBoundActionList = { + [A in TAction]?: Array>; + } + export type TActionHandlerOptions = | MutableRefObject + | boolean + | undefined + +## apps/web/src/lib/auth + +server.ts + export const auth + export type Auth = typeof auth + +## apps/web/src/lib/blog + +query.ts + export function getPosts() + export function getTags() + export function getSinglePost({ slug }: { slug: string }) + export function getCategories() + export function getAuthors() + export function processHtmlContent({ + html, + }: { + html: string; + }): Promise + +## apps/web/src/lib/db + +index.ts + export const db + +schema.ts + export const users + export const sessions + export const accounts + export const verifications + +## apps/web/src/lib/gradients + +canvas.ts + export function drawCssBackground({ + ctx, + width, + height, + css, + }: { + ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; + width: number; + height: number; + css: string; + }): void + +parser.ts + export type GradientOrientation = LinearOrientation | Array + export type Color = | { type: "hex"; value: string } + | { type: "literal"; value: string } + | { type: "rgb"; value: A... + export type ColorStop = Color & { length?: Distance } + export type GradientAst = { + type: GradientType; + orientation: GradientOrientation | undefined; + colorStops: Array => ... + export const GradientParser + +## apps/web/src/lib/media + +audio.ts + export type CollectedAudioElement = Omit< + AudioElement, + "type" | "mediaId" | "volume" | "id" | "name" | "sourceType" | "sourceUrl" + ... + export function createAudioContext(): AudioContext + export interface DecodedAudio { + samples: Float32Array + sampleRate: number + } + export function decodeAudioToFloat32({ + audioBlob, + }: { + audioBlob: Blob; + }): Promise + export function collectAudioElements({ + tracks, + mediaAssets, + audioContext, + }: { + tracks: TimelineTrack[]; + mediaAssets: MediaAsset[]; + audioContext: AudioContext; + }): Promise + export interface AudioClipSource { + id: string + sourceKey: string + file: File + startTime: number + duration: number + trimStart: number + trimEnd: number + muted: boolean + } + export function collectAudioMixSources({ + tracks, + mediaAssets, + }: { + tracks: TimelineTrack[]; + mediaAssets: MediaAsset[]; + }): Promise + export function collectAudioClips({ + tracks, + mediaAssets, + }: { + tracks: TimelineTrack[]; + mediaAssets: MediaAsset[]; + }): Promise + export function createTimelineAudioBuffer({ + tracks, + mediaAssets, + duration, + sampleRate = 44100, + audioContext, + }: { + tracks: TimelineTrack[]; + mediaAssets: MediaAsset[]; + duration: number; + sampleRate?: number; + audioContext?: AudioContext; + }): Promise + +media-utils.ts + export const SUPPORTS_AUDIO: readonly MediaType[] + export function mediaSupportsAudio({ + media, + }: { + media: MediaAsset | null | undefined; + }): boolean + export const getMediaTypeFromFile = ({ + file, + }: { + file: File; + }): MediaType | null => ... + +mediabunny.ts + export function getVideoInfo({ + videoFile, + }: { + videoFile: File; + }): Promise<{ + duration: number; + width: number; + height: number; + fps: number; + }> + export const extractTimelineAudio = ({ + tracks, + mediaAssets, + totalDuration, + onProgress, + }: { + tracks: TimelineTrack[]; + mediaAssets: MediaAsset[]; + totalDuration: number; + onProgress?: (progress: number) => void; + }): Promise => ... + +processing.ts + export interface ProcessedMediaAsset extends Omit + export function generateThumbnail({ + videoFile, + timeInSeconds, + }: { + videoFile: File; + timeInSeconds: number; + }): Promise + export function generateImageThumbnail({ + imageFile, + }: { + imageFile: File; + }): Promise + export function processMediaAssets({ + files, + onProgress, + }: { + files: FileList | File[]; + onProgress?: ({ progress }: { progress: number }) => void; + }): Promise + +## apps/web/src/lib/timeline + +bookmarks.ts + export function findBookmarkIndex({ + bookmarks, + frameTime, + }: { + bookmarks: number[]; + frameTime: number; + }): number + export function isBookmarkAtTime({ + bookmarks, + frameTime, + }: { + bookmarks: number[]; + frameTime: number; + }): boolean + export function toggleBookmarkInArray({ + bookmarks, + frameTime, + }: { + bookmarks: number[]; + frameTime: number; + }): number[] + export function removeBookmarkFromArray({ + bookmarks, + frameTime, + }: { + bookmarks: number[]; + frameTime: number; + }): number[] + export function getFrameTime({ + time, + fps, + }: { + time: number; + fps: number; + }): number + +drop-utils.ts + export function computeDropTarget({ + elementType, + mouseX, + mouseY, + tracks, + playheadTime, + isExternalDrop, + elementDuration, + pixelsPerSecond, + zoomLevel, + verticalDragDirection, + startTimeOverride, + excludeElementId, + }: ComputeDropTargetParams): DropTarget + export function getDropLineY({ + dropTarget, + tracks, + }: { + dropTarget: DropTarget; + tracks: TimelineTrack[]; + }): number + +element-utils.ts + export function canElementHaveAudio( + element: TimelineElement, + ) + export function canElementBeHidden( + element: TimelineElement, + ) + export function hasMediaId( + element: TimelineElement, + ) + export function requiresMediaId({ + element, + }: { + element: CreateTimelineElement; + }): boolean + export function checkElementOverlaps({ + elements, + }: { + elements: TimelineElement[]; + }): boolean + export function resolveElementOverlaps({ + elements, + }: { + elements: TimelineElement[]; + }): TimelineElement[] + export function wouldElementOverlap({ + elements, + startTime, + endTime, + excludeElementId, + }: { + elements: TimelineElement[]; + startTime: number; + endTime: number; + excludeElementId?: string; + }): boolean + export function buildTextElement({ + raw, + startTime, + }: { + raw: Partial>; + startTime: number; + }): CreateTimelineElement + export function buildStickerElement({ + iconName, + startTime, + }: { + iconName: string; + startTime: number; + }): CreateStickerElement + export function buildVideoElement({ + mediaId, + name, + duration, + startTime, + }: { + mediaId: string; + name: string; + duration: number; + startTime: number; + }): CreateVideoElement + export function buildImageElement({ + mediaId, + name, + duration, + startTime, + }: { + mediaId: string; + name: string; + duration: number; + startTime: number; + }): CreateImageElement + export function buildUploadAudioElement({ + mediaId, + name, + duration, + startTime, + buffer, + }: { + mediaId: string; + name: string; + duration: number; + startTime: number; + buffer?: AudioBuffer; + }): CreateUploadAudioElement + export function buildLibraryAudioElement({ + sourceUrl, + name, + duration, + startTime, + buffer, + }: { + sourceUrl: string; + name: string; + duration: number; + startTime: number; + buffer?: AudioBuffer; + }): CreateLibraryAudioElement + export function getElementsAtTime({ + tracks, + time, + }: { + tracks: TimelineTrack[]; + time: number; + }): { trackId: string; elementId: string }[] + +index.ts + export function calculateTotalDuration({ + tracks, + }: { + tracks: TimelineTrack[]; + }): number + +ruler-utils.ts + export interface RulerConfig { + labelIntervalSeconds: number + tickIntervalSeconds: number + } + export function getRulerConfig({ + zoomLevel, + fps, + }: { + zoomLevel: number; + fps: number; + }): RulerConfig + export function shouldShowLabel({ + time, + labelIntervalSeconds, + }: { + time: number; + labelIntervalSeconds: number; + }): boolean + export function formatRulerLabel({ + timeInSeconds, + fps, + }: { + timeInSeconds: number; + fps: number; + }): string + +track-utils.ts + export function canTracktHaveAudio( + track: TimelineTrack, + ) + export function canTrackBeHidden( + track: TimelineTrack, + ) + export function getTrackColor({ type }: { type: TrackType }) + export function getTrackClasses({ type }: { type: TrackType }) + export function getTrackHeight({ type }: { type: TrackType }): number + export function getCumulativeHeightBefore({ + tracks, + trackIndex, + }: { + tracks: Array<{ type: TrackType }>; + trackIndex: number; + }): number + export function getTotalTracksHeight({ + tracks, + }: { + tracks: Array<{ type: TrackType }>; + }): number + export function buildEmptyTrack({ + id, + type, + name, + }: { + id: string; + type: TrackType; + name?: string; + }): TimelineTrack + export function getDefaultInsertIndexForTrack({ + tracks, + trackType, + }: { + tracks: TimelineTrack[]; + trackType: TrackType; + }): number + export function getHighestInsertIndexForTrack({ + tracks, + trackType, + }: { + tracks: TimelineTrack[]; + trackType: TrackType; + }): number + export function isMainTrack(track: TimelineTrack) + export function getMainTrack({ + tracks, + }: { + tracks: TimelineTrack[]; + }): TimelineTrack | null + export function ensureMainTrack({ + tracks, + }: { + tracks: TimelineTrack[]; + }): TimelineTrack[] + export function canElementGoOnTrack({ + elementType, + trackType, + }: { + elementType: ElementType; + trackType: TrackType; + }): boolean + export function validateElementTrackCompatibility({ + element, + track, + }: { + element: { type: ElementType }; + track: { type: TrackType }; + }): { isValid: boolean; errorMessage?: string } + +zoom-utils.ts + export function getTimelineZoomMin({ + duration, + containerWidth, + }: { + duration: number; + containerWidth: number | null | undefined; + }): number + export function getTimelinePaddingPx({ + containerWidth, + zoomLevel, + minZoom, + }: { + containerWidth: number; + zoomLevel: number; + minZoom: number; + }): number + export function getZoomPercent({ + zoomLevel, + minZoom, + }: { + zoomLevel: number; + minZoom: number; + }): number + +## apps/web/src/lib/transcription + +caption.ts + export function buildCaptionChunks({ + segments, + wordsPerChunk = DEFAULT_WORDS_PER_CAPTION, + minDuration = MIN_CAPTION_DURATION_SECONDS, + }: { + segments: TranscriptionSegment[]; + wordsPerChunk?: number; + minDuration?: number; + }): CaptionChunk[] + +## apps/web/src/services/media + +video-cache.ts + export class VideoCache { + sinks + initPromises + async getFrameAt({ + mediaId, + file, + time, + }: { + mediaId: string; + file: File; + time: number; + }): Promise + clearVideo({ mediaId }: { mediaId: string }): void + clearAll(): void + getStats() + } + export const videoCache + +## apps/web/src/services/renderer + +canvas-renderer.ts + export type CanvasRendererParams = { + width: number; + height: number; + fps: number; + } + export class CanvasRenderer { + canvas: OffscreenCanvas | HTMLCanvasElement + context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D + width: number + height: number + fps: number + constructor({ width, height, fps }: CanvasRendererParams) + setSize({ width, height }: { width: number; height: number }) + async render({ node, time }: { node: BaseNode; time: number }) + async renderToCanvas({ + node, + time, + targetCanvas, + }: { + node: BaseNode; + time: number; + targetCanvas: HTMLCanvasElement; + }) + } + +scene-builder.ts + export type BuildSceneParams = { + canvasSize: TCanvasSize; + tracks: TimelineTrack[]; + mediaAssets: MediaAsset[]; + duration: numb... + export function buildScene(params: BuildSceneParams) + +scene-exporter.ts + export type ExportFormat = "mp4" | "webm" + export type ExportQuality = "low" | "medium" | "high" | "very_high" + export type SceneExporterEvents = { + progress: [progress: number]; + complete: [buffer: ArrayBuffer]; + error: [error: Error]; + cance... + export class SceneExporter extends EventEmitter { + renderer: CanvasRenderer + format: ExportFormat + quality: ExportQuality + shouldIncludeAudio: boolean + audioBuffer: AudioBuffer + isCancelled + constructor({ + width, + height, + fps, + format, + quality, + shouldIncludeAudio, + audioBuffer, + }: ExportParams) + cancel(): void + async export({ + rootNode, + }: { + rootNode: RootNode; + }): Promise + } + +## apps/web/src/services/storage + +indexeddb-adapter.ts + export class IndexedDBAdapter implements StorageAdapter { + dbName: string + storeName: string + version: number + constructor(dbName: string, storeName: string, version = 1) + async get(key: string): Promise + async set(key: string, value: T): Promise + async remove(key: string): Promise + async list(): Promise + async getAll(): Promise + async clear(): Promise + } + +opfs-adapter.ts + export class OPFSAdapter implements StorageAdapter { + directoryName: string + constructor(directoryName = "media") + async get(key: string): Promise + async set(key: string, file: File): Promise + async remove(key: string): Promise + async list(): Promise + async clear(): Promise + static isSupported(): boolean + } + +storage-service.ts + export const storageService + +types.ts + export interface StorageAdapter { + get(key: string): Promise + set(key: string, value: T): Promise + remove(key: string): Promise + list(): Promise + clear(): Promise + } + export interface MediaAssetData { + id: string + name: string + type: MediaType + size: number + lastModified: number + width?: number + height?: number + duration?: number + fps?: number + ephemeral?: boolean + thumbnailUrl?: string + sourceStickerIconName?: string + } + export type SerializedScene = Omit & { + createdAt: string; + updatedAt: string; + } + export type SerializedProjectMetadata = Omit< + TProjectMetadata, + "createdAt" | "updatedAt" + > & { + createdAt: string; + updatedAt: string; + } + export type SerializedProject = Omit & { + metadata: SerializedProjectMetadata; + scenes: Serializ... + export interface StorageConfig { + projectsDb: string + mediaDb: string + savedSoundsDb: string + version: number + } + +## apps/web/src/services/transcription + +index.ts + export const transcriptionService + +worker.ts + export type WorkerMessage = | { type: "init"; modelId: string } + | { type: "transcribe"; audio: Float32Array; language: strin... + export type WorkerResponse = | { type: "init-progress"; progress: number } + | { type: "init-complete" } + | { type: "init-error... + +## apps/web/src/stores + +assets-panel-store.tsx + export const TAB_KEYS + export type Tab = (typeof TAB_KEYS)[number] + export const tabs + export const useAssetsPanelStore + +editor-store.ts + export const useEditorStore + +keybindings-store.ts + export const defaultKeybindings: KeybindingConfig + export interface KeybindingConflict { + key: ShortcutKey + existingAction: TActionWithOptionalArgs + newAction: TActionWithOptionalArgs + } + export const useKeybindingsStore + +panel-store.ts + export interface PanelSizes { + tools: number + preview: number + properties: number + mainContent: number + timeline: number + } + export type PanelId = keyof PanelSizes + export const usePanelStore + +sounds-store.ts + export const useSoundsStore + +stickers-store.ts + export const useStickersStore + +text-properties-store.ts + export type TextPropertiesTab = "text" | "transform" + export interface TextPropertiesTabMeta { + value: TextPropertiesTab + label: string + } + export const TEXT_PROPERTIES_TABS: ReadonlyArray + export function isTextPropertiesTab(value: string) + export const useTextPropertiesStore + +timeline-store.ts + export const useTimelineStore + +## apps/web/src/types + +assets.ts + export type MediaType = "image" | "video" | "audio" + export interface MediaAsset extends Omit { + file: File + url?: string + } + +blog.ts + export type Post = { + id: string; + slug: string; + title: string; + content: string; + description: string; + coverImage... + export type Pagination = { + limit: number; + currpage: number; + nextPage: number | null; + prevPage: number | null; + totalIt... + export type MarblePostList = { + posts: Post[]; + pagination: Pagination; + } + export type MarblePost = { + post: Post; + } + export type Tag = { + id: string; + name: string; + slug: string; + } + export type MarbleTag = { + tag: Tag; + } + export type MarbleTagList = { + tags: Tag[]; + pagination: Pagination; + } + export type Category = { + id: string; + name: string; + slug: string; + } + export type MarbleCategory = { + category: Category; + } + export type MarbleCategoryList = { + categories: Category[]; + pagination: Pagination; + } + export type Author = { + id: string; + name: string; + image: string; + } + export type MarbleAuthor = { + author: Author; + } + export type MarbleAuthorList = { + authors: Author[]; + pagination: Pagination; + } + +drag.ts + export interface MediaDragData extends BaseDragData { + type: "media" + mediaType: "image" | "video" | "audio" + } + export interface TextDragData extends BaseDragData { + type: "text" + content: string + } + export interface StickerDragData extends BaseDragData { + type: "sticker" + iconName: string + } + export type TimelineDragData = MediaDragData | TextDragData | StickerDragData + +editor.ts + export type TPlatformLayout = "tiktok" + +export.ts + export const EXPORT_QUALITY_VALUES + export const EXPORT_FORMAT_VALUES + export type ExportFormat = (typeof EXPORT_FORMAT_VALUES)[number] + export type ExportQuality = (typeof EXPORT_QUALITY_VALUES)[number] + export interface ExportOptions { + format: ExportFormat + quality: ExportQuality + fps?: number + includeAudio?: boolean + onProgress?: ({ progress }: { progress: number }) => void + onCancel?: () => boolean + } + export interface ExportResult { + success: boolean + buffer?: ArrayBuffer + error?: string + cancelled?: boolean + } + +keybinding.ts + export type ModifierKeys = | "ctrl" + | "alt" + | "shift" + | "ctrl+shift" + | "alt+shift" + | "ctrl+alt" + | "ctrl+alt+shift" + export type Key = | "a" + | "b" + | "c" + | "d" + | "e" + | "f" + | "g" + | "h" + | "i" + | "j" + | "k" + | "l" + | "m" + | "n" + ... + export type ModifierBasedShortcutKey = `${ModifierKeys}+${Key}` + export type SingleCharacterShortcutKey = `${Key}` + export type ShortcutKey = ModifierBasedShortcutKey | SingleCharacterShortcutKey + export type KeybindingConfig = { + [key in ShortcutKey]?: TActionWithOptionalArgs; + } + +language.ts + export type Language = (typeof LANGUAGES)[number] + export type LanguageCode = Language["code"] + +project.ts + export type TBackground = | { + type: "color"; + color: string; + } + | { + type: "blur"; + blurIntensity: number; + } + export interface TCanvasSize { + width: number + height: number + } + export interface TProjectMetadata { + id: string + name: string + thumbnail?: string + duration: number + createdAt: Date + updatedAt: Date + } + export interface TProjectSettings { + fps: number + canvasSize: TCanvasSize + originalCanvasSize?: TCanvasSize | null + background: TBackground + } + export interface TTimelineViewState { + zoomLevel: number + scrollLeft: number + playheadTime: number + } + export interface TProject { + metadata: TProjectMetadata + scenes: TScene[] + currentSceneId: string + settings: TProjectSettings + version: number + timelineViewState?: TTimelineViewState + } + export type TProjectSortKey = "createdAt" | "updatedAt" | "name" | "duration" + export type TSortOrder = "asc" | "desc" + export type TProjectSortOption = `${TProjectSortKey}-${TSortOrder}` + +sounds.ts + export interface SoundEffect { + id: number + name: string + description: string + url: string + previewUrl?: string + downloadUrl?: string + duration: number + filesize: number + type: string + channels: number + bitrate: number + bitdepth: number + samplerate: number + username: string + tags: string[] + license: string + created: string + downloads: number + rating: number + ratingCount: number + } + export interface SavedSound { + id: number + name: string + username: string + previewUrl?: string + downloadUrl?: string + duration: number + tags: string[] + license: string + savedAt: string + } + export interface SavedSoundsData { + sounds: SavedSound[] + lastModified: string + } + +stickers.ts + export type StickerCategory = (typeof STICKER_CATEGORIES)[number] + +time.ts + export type TTimeCode = "MM:SS" | "HH:MM:SS" | "HH:MM:SS:CS" | "HH:MM:SS:FF" + +timeline.ts + export interface TScene { + id: string + name: string + isMain: boolean + tracks: TimelineTrack[] + bookmarks: number[] + createdAt: Date + updatedAt: Date + } + export type TrackType = "video" | "text" | "audio" | "sticker" + export interface VideoTrack extends BaseTrack { + type: "video" + elements: (VideoElement | ImageElement)[] + isMain: boolean + muted: boolean + hidden: boolean + } + export interface TextTrack extends BaseTrack { + type: "text" + elements: TextElement[] + hidden: boolean + } + export interface AudioTrack extends BaseTrack { + type: "audio" + elements: AudioElement[] + muted: boolean + } + export interface StickerTrack extends BaseTrack { + type: "sticker" + elements: StickerElement[] + hidden: boolean + } + export type TimelineTrack = VideoTrack | TextTrack | AudioTrack | StickerTrack + export interface Transform { + scale: number + position: { + x: number; + y: number; + } + rotate: number + } + export interface UploadAudioElement extends BaseAudioElement { + sourceType: "upload" + mediaId: string + } + export interface LibraryAudioElement extends BaseAudioElement { + sourceType: "library" + sourceUrl: string + } + export type AudioElement = UploadAudioElement | LibraryAudioElement + export interface VideoElement extends BaseTimelineElement { + type: "video" + mediaId: string + muted?: boolean + hidden?: boolean + transform: Transform + opacity: number + } + export interface ImageElement extends BaseTimelineElement { + type: "image" + mediaId: string + hidden?: boolean + transform: Transform + opacity: number + } + export interface TextElement extends BaseTimelineElement { + type: "text" + content: string + fontSize: number + fontFamily: string + color: string + backgroundColor: string + textAlign: "left" | "center" | "right" + fontWeight: "normal" | "bold" + fontStyle: "normal" | "italic" + textDecoration: "none" | "underline" | "line-through" + hidden?: boolean + transform: Transform + opacity: number + } + export interface StickerElement extends BaseTimelineElement { + type: "sticker" + iconName: string + hidden?: boolean + transform: Transform + opacity: number + color?: string + } + export type TimelineElement = | AudioElement + | VideoElement + | ImageElement + | TextElement + | StickerElement + export type ElementType = TimelineElement["type"] + export type CreateUploadAudioElement = Omit + export type CreateLibraryAudioElement = Omit + export type CreateAudioElement = | CreateUploadAudioElement + | CreateLibraryAudioElement + export type CreateVideoElement = Omit + export type CreateImageElement = Omit + export type CreateTextElement = Omit + export type CreateStickerElement = Omit + export type CreateTimelineElement = | CreateAudioElement + | CreateVideoElement + | CreateImageElement + | CreateTextElement + | CreateSt... + export interface ElementDragState { + isDragging: boolean + elementId: string | null + trackId: string | null + startMouseX: number + startMouseY: number + startElementTime: number + clickOffsetTime: number + currentTime: number + currentMouseY: number + } + export interface DropTarget { + trackIndex: number + isNewTrack: boolean + insertPosition: "above" | "below" | null + xPosition: number + } + export interface ComputeDropTargetParams { + elementType: ElementType + mouseX: number + mouseY: number + tracks: TimelineTrack[] + playheadTime: number + isExternalDrop: boolean + elementDuration: number + pixelsPerSecond: number + zoomLevel: number + verticalDragDirection?: "up" | "down" | null + startTimeOverride?: number + excludeElementId?: string + } + export interface ClipboardItem { + trackId: string + trackType: TrackType + element: CreateTimelineElement + } + +transcription.ts + export type TranscriptionLanguage = LanguageCode | "auto" + export interface TranscriptionSegment { + text: string + start: number + end: number + } + export interface TranscriptionResult { + text: string + segments: TranscriptionSegment[] + language: string + } + export type TranscriptionStatus = | "idle" + | "loading-model" + | "transcribing" + | "complete" + | "error" + export interface TranscriptionProgress { + status: TranscriptionStatus + progress: number + message?: string + } + export type TranscriptionModelId = | "whisper-tiny" + | "whisper-small" + | "whisper-medium" + | "whisper-large-v3-turbo" + export interface TranscriptionModel { + id: TranscriptionModelId + name: string + huggingFaceId: string + description: string + } + export interface CaptionChunk { + text: string + startTime: number + duration: number + } + +## apps/web/src/utils + +browser.ts + export function isTypableDOMElement({ + element, + }: { + element: HTMLElement; + }): boolean + +date.ts + export function formatDate({ date }: { date: Date }): string + +geometry.ts + export function dimensionToAspectRatio({ + width, + height, + }: { + width: number; + height: number; + }): string + +id.ts + export function generateUUID(): string + +math.ts + export function clamp({ + value, + min, + max, + }: { + value: number; + min: number; + max: number; + }): number + +platform.ts + export function getPlatformSpecialKey(): string + export function getPlatformAlternateKey(): string + export function isAppleDevice(): boolean + +string.ts + export function capitalizeFirstLetter({ string }: { string: string }) + export function uppercase({ string }: { string: string }) + +ui.ts + export function cn(...inputs: ClassValue[]): string + +## packages/ui/src/icons + +brand.tsx + export function OcVercelIcon({ className }: { className?: string }) + export function OcMarbleIcon({ + className = "", + size = 32, + }: { + className?: string; + size?: number; + }) + export function OcDataBuddyIcon({ + className = "", + size = 32, + }: { + className?: string; + size?: number; + }) + +ui.tsx + export function OcVideoIcon({ + className = "", + size = 32, + }: { + className?: string; + size?: number; + }) + +``` + +--- + +*Generated and maintained by [Twiggy](https://github.com/twiggy-tools/Twiggy)* diff --git a/.cursor/rules/comments.mdc b/.cursor/rules/comments.mdc new file mode 100644 index 000000000..e732e8594 --- /dev/null +++ b/.cursor/rules/comments.mdc @@ -0,0 +1,35 @@ +--- +alwaysApply: true +--- + +# Comment Guidelines + +## Good Comments (Human-style) +- Explain WHY, not WHAT +- Document non-obvious behavior or edge cases +- Warn about performance implications or side effects +- Explain business logic that isn't clear from code + +Examples: +```javascript +// transfer, not copy; sender buffer detaches +// satisfies: check shape; keep literals +// keep multibyte across chunks +// timingSafeEqual throws on length mismatch +``` + +## Bad Comments (AI-style) +- Don't explain what the code literally does +- Don't add changelog-style comments in code +- Don't comment every line or obvious operations + +Avoid: +```javascript +// Prevent duplicate initialization +// Check if project is already loaded +// Mark as initializing to prevent race conditions +// (changed from blah to blah) +``` + +## Rule +Only add comments when there's genuinely non-obvious behavior, performance considerations, or business logic that needs context. Code should be self-documenting through naming and structure. \ No newline at end of file diff --git a/.cursor/rules/handling-uncertainty.mdc b/.cursor/rules/handling-uncertainty.mdc new file mode 100644 index 000000000..c6bae7304 --- /dev/null +++ b/.cursor/rules/handling-uncertainty.mdc @@ -0,0 +1,21 @@ +--- +alwaysApply: true +--- + +# Handling Uncertainty + +## Principle +If you can't confidently respond due to missing context, data access, or ambiguity (multiple interpretations), report it instead of guessing. Seek clarification to avoid errors. + +Apply when: query lacks details, no access to info/tools, or unclear intent. + +## How to Report +1. **Description**: Why uncertain and what you need. +2. **Questions**: 1-3 targeted ones. +3. **Assumptions** (opt.): State if proceeding; omit otherwise. + +Direct and concise. + +**Assumptions**: None. + +Builds transparency. \ No newline at end of file diff --git a/.cursor/rules/readability.mdc b/.cursor/rules/readability.mdc new file mode 100644 index 000000000..6be08cf73 --- /dev/null +++ b/.cursor/rules/readability.mdc @@ -0,0 +1,9 @@ +--- +alwaysApply: true +--- + +# Readability First + +Optimize code for AI agents to understand and modify. + +Never abbreviate. `event` not `e`, `element` not `el`. If it's easy to read, it's correct. In this case, "config" is better than "configuration" because it's shorter and is **still very readable**. "El" is not very readable. diff --git a/.cursor/rules/separation-of-concerns.mdc b/.cursor/rules/separation-of-concerns.mdc new file mode 100644 index 000000000..0dd97118c --- /dev/null +++ b/.cursor/rules/separation-of-concerns.mdc @@ -0,0 +1,52 @@ +--- +alwaysApply: true +--- + +# Separation of Concerns + +## Core Principle + +Each file should have one single purpose/responsibility. Related functionality should be grouped together, unrelated functionality should be separated. + +## Good Separation + +- One file per major concern (auth, validation, data transformation) +- Group related utilities together +- Extract shared logic into dedicated files +- Keep API routes focused on their specific endpoint logic + +Examples: + +```javascript +// ✅ Good: Each file has clear responsibility +/lib/rate-limit.ts // Rate limiting utilities +/lib/validation.ts // Input validation schemas +/lib/freesound-api.ts // External API integration +/api/sounds/search/route.ts // Route handler only +``` + +## Bad Mixing of Concerns + +Avoid cramming multiple responsibilities into one file: + +```javascript +// ❌ Bad: Route file doing everything +/api/sounds/search/route.ts +- Rate limiting logic +- Validation schemas +- API transformation +- External API calls +- Response formatting +- Error handling utilities +``` + +## When to Separate + +- File is getting long (>500 lines) +- Multiple distinct responsibilities in one file +- Logic could be reused elsewhere +- Complex utilities that distract from main purpose + +## Rule + +One file, one responsibility. Extract shared concerns into focused utility files \ No newline at end of file diff --git a/.cursor/rules/ultracite.mdc b/.cursor/rules/ultracite.mdc index 4be535afc..977f43632 100644 --- a/.cursor/rules/ultracite.mdc +++ b/.cursor/rules/ultracite.mdc @@ -6,7 +6,7 @@ alwaysApply: true # Project Context -Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter. +Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's formatter. ## Key Principles @@ -43,7 +43,6 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c ### React and JSX Best Practices - Don't import `React` itself. -- Don't define React components inside other components. - Don't use both `children` and `dangerouslySetInnerHTML` props on the same element. - Don't insert comments as text nodes. - Use `<>...` instead of `...`. diff --git a/.cursor/rules/writing-scannable-code.mdc b/.cursor/rules/writing-scannable-code.mdc new file mode 100644 index 000000000..97ed87e94 --- /dev/null +++ b/.cursor/rules/writing-scannable-code.mdc @@ -0,0 +1,53 @@ +--- +alwaysApply: true +--- + +# Scannable Code Guidelines/Separating Concerns. + +## Core Principle + +Code should be scannable through proper abstraction, not comments. Use variables and helper functions to make intent clear at a glance. + +## Good Scannable Code + +- Extract complex logic into well-named variables +- Create helper functions for multi-step operations +- Use descriptive names that explain intent + +Examples: + +```javascript +// ✅ Scannable: Intent is clear from variable names +const isValidUser = user.isActive && user.hasPermissions; +const shouldProcessPayment = amount > 0 && !order.isPaid; + +// ✅ Scannable: Complex logic extracted to helper +const searchParams = buildFreesoundSearchParams({ query, filters, pagination }); +const transformedResults = transformFreesoundResults({ rawResults }); +``` + +## Bad Unscannable Code + +Avoid: + +```javascript +// ❌ Hard to scan: What does this condition mean? +if (type === "effects" || !type) { + params.append("filter", "duration:[* TO 30.0]"); + params.append("filter", `avg_rating:[${min_rating} TO *]`); + if (commercial_only) { + params.append("filter", 'license:("Attribution" OR "Creative Commons 0")'); + } +} + +// ❌ Hard to scan: Complex ternary +const sortParam = query + ? sort === "score" + ? "score" + : `${sort}_desc` + : `${sort}_desc`; +``` + +## Rule + +Make code scannable by extracting intent into variables and helper functions. If you need to think about what code does, extract it. The reader should understand the flow without diving into implementation details. \ No newline at end of file diff --git a/.cursorignore b/.cursorignore deleted file mode 100644 index 633521a20..000000000 --- a/.cursorignore +++ /dev/null @@ -1 +0,0 @@ -!apps/web/.env.example \ No newline at end of file diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index b6d678724..2661e1829 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -17,23 +17,23 @@ diverse, inclusive, and healthy community. Examples of behavior that contributes to a positive environment for our community include: -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience -* Focusing on what is best not just for us as individuals, but for the overall +- Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: -* The use of sexualized language or imagery, and sexual attention or advances of +- The use of sexualized language or imagery, and sexual attention or advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email address, +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a +- Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 75c4120b2..f1b425e4d 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -98,11 +98,11 @@ If you're unsure whether your idea falls into the preview category, feel free to ```bash # Database (matches docker-compose.yaml) - DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut" + DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut" # Generate a secure secret for Better Auth BETTER_AUTH_SECRET="your-generated-secret-here" - NEXT_PUBLIC_BETTER_AUTH_URL="http://localhost:3000" + NEXT_PUBLIC_SITE_URL="http://localhost:3000" # Redis (matches docker-compose.yaml) UPSTASH_REDIS_REST_URL="http://localhost:8079" diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c9684d1fb..01965d41c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,6 +1,6 @@ name: Bug report description: Create a report to help us improve -title: '[BUG] ' +title: "[BUG] " labels: bug body: - type: input @@ -15,7 +15,7 @@ body: id: Browser attributes: label: Browser - description: Please enter the browser on which you encountered the bug. + description: Please enter the browser on which you encountered the bug. placeholder: e.g. Chrome 137, Firefox 137, Safari 17 validations: required: true @@ -67,4 +67,4 @@ body: Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. validations: - required: false \ No newline at end of file + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 49a00eb6a..336e2d6a1 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,6 +1,6 @@ name: Feature request description: Suggest an idea for OpenCut -title: '[FEATURE] ' +title: "[FEATURE] " labels: enhancement body: - type: markdown @@ -39,4 +39,4 @@ body: Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. validations: - required: false \ No newline at end of file + required: false diff --git a/.github/SECURITY.md b/.github/SECURITY.md index f6d94c5ee..1369bbabb 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -25,4 +25,4 @@ Please do not report security vulnerabilities through public GitHub issues. - We will provide a detailed response within 5 business days - We will keep you updated on our progress -Thank you for helping keep OpenCut secure! \ No newline at end of file +Thank you for helping keep OpenCut secure! diff --git a/.github/SUPPORT.md b/.github/SUPPORT.md index 892a0f7a9..1df3d7bd6 100644 --- a/.github/SUPPORT.md +++ b/.github/SUPPORT.md @@ -3,21 +3,25 @@ Thanks for using OpenCut! If you need help, here are your options: ## Documentation + - Check our [README](../README.md) for basic setup instructions - Review the [Contributing Guidelines](CONTRIBUTING.md) for development setup ## Issues + - **Bug reports**: Use the bug report template - **Feature requests**: Use the feature request template - **Questions**: Use GitHub Discussions for general questions ## Community + - Join our discussions on GitHub - Follow the [Code of Conduct](CODE_OF_CONDUCT.md) ## Response Times + - Issues are typically triaged within 2-3 business days - Feature requests may take longer to evaluate - Security issues are handled with priority -We appreciate your patience and contributions to making OpenCut better! \ No newline at end of file +We appreciate your patience and contributions to making OpenCut better! diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 5d6e71f2b..77e361d14 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -3,15 +3,18 @@ applyTo: "**/*.{ts,tsx,js,jsx}" --- # Project Context + Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter. ## Key Principles + - Zero configuration required - Subsecond performance - Maximum type safety - AI-friendly code generation ## Before Writing Code + 1. Analyze existing patterns in the codebase 2. Consider edge cases and error scenarios 3. Follow the rules below strictly @@ -20,6 +23,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c ## Rules ### Accessibility (a11y) + - Don't use `accessKey` attribute on any HTML element. - Don't set `aria-hidden="true"` on focusable elements. - Don't add ARIA roles, states, and properties to elements that don't support them. @@ -56,6 +60,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c - Use correct ISO language/country codes for the `lang` attribute. ### Code Complexity and Quality + - Don't use consecutive spaces in regular expression literals. - Don't use the `arguments` object. - Don't use primitive type aliases or misleading types. @@ -111,6 +116,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c - Don't use literal numbers that lose precision. ### React and JSX Best Practices + - Don't use the return value of React.render. - Make sure all dependencies are correctly specified in React hooks. - Make sure all React hooks are called from the top level of component functions. @@ -129,6 +135,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c - Watch out for possible "wrong" semicolons inside JSX elements. ### Correctness and Safety + - Don't assign a value to itself. - Don't return a value from a setter. - Don't compare expressions that modify string case with non-compliant values. @@ -153,7 +160,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c - Don't use bitwise operators. - Don't use expressions where the operation doesn't change the value. - Make sure Promise-like statements are handled appropriately. -- Don't use __dirname and __filename in the global scope. +- Don't use **dirname and **filename in the global scope. - Prevent import cycles. - Don't use configured elements. - Don't hardcode sensitive data like API keys and tokens. @@ -184,6 +191,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c - Don't use `target="_blank"` without `rel="noopener"`. ### TypeScript Best Practices + - Don't use TypeScript enums. - Don't export imported variables. - Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions. @@ -208,6 +216,7 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c - Use the namespace keyword instead of the module keyword to declare TypeScript namespaces. ### Style and Consistency + - Don't use global `eval()`. - Don't use callbacks in asynchronous tests and hooks. - Don't use negation in `if` statements that have `else` clauses. @@ -295,30 +304,34 @@ Ultracite enforces strict type safety, accessibility standards, and consistent c - Make sure to use the "use strict" directive in script files. ### Next.js Specific Rules + - Don't use `` elements in Next.js projects. - Don't use `` elements in Next.js projects. -- Don't import next/document outside of pages/_document.jsx in Next.js projects. -- Don't use the next/head module in pages/_document.js on Next.js projects. +- Don't import next/document outside of pages/\_document.jsx in Next.js projects. +- Don't use the next/head module in pages/\_document.js on Next.js projects. ### Testing Best Practices + - Don't use export or module.exports in test files. - Don't use focused tests. - Make sure the assertion function, like expect, is placed inside an it() function call. - Don't use disabled tests. ## Common Tasks + - `npx ultracite init` - Initialize Ultracite in your project - `npx ultracite format` - Format and fix code automatically - `npx ultracite lint` - Check for issues without fixing ## Example: Error Handling + ```typescript // ✅ Good: Comprehensive error handling try { const result = await fetchData(); return { success: true, data: result }; } catch (error) { - console.error('API call failed:', error); + console.error("API call failed:", error); return { success: false, error: error.message }; } @@ -328,4 +341,4 @@ try { } catch (e) { console.log(e); } -``` \ No newline at end of file +``` diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index b7686c4b9..b9664c768 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -24,9 +24,10 @@ Please describe the tests that you ran to verify your changes. Provide instructi - [ ] Test B **Test Configuration**: -* Node version: -* Browser (if applicable): -* Operating System: + +- Node version: +- Browser (if applicable): +- Operating System: ## Screenshots (if applicable) @@ -46,4 +47,4 @@ Add screenshots to help explain your changes. ## Additional context -Add any other context about the pull request here. +Add any other context about the pull request here. diff --git a/.github/workflows/bun-ci.yml b/.github/workflows/bun-ci.yml index f01b98780..d3e4dcb7e 100644 --- a/.github/workflows/bun-ci.yml +++ b/.github/workflows/bun-ci.yml @@ -22,9 +22,9 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] env: - DATABASE_URL: "postgresql://opencut:opencutthegoat@localhost:5432/opencut" + DATABASE_URL: "postgresql://opencut:opencut@localhost:5432/opencut" BETTER_AUTH_SECRET: "supersecret" - NEXT_PUBLIC_BETTER_AUTH_URL: "http://localhost:3000" + NEXT_PUBLIC_SITE_URL: "http://localhost:3000" UPSTASH_REDIS_REST_URL: "https://your-upstash-redis-url" UPSTASH_REDIS_REST_TOKEN: "your-upstash-redis-token" diff --git a/.gitignore b/.gitignore index 31d36b92a..e2b17bce7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,22 +1,5 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. -# dependencies -/apps/web/node_modules - -# next.js -/apps/web/.next/ -/apps/web/out/ - -# debug -/apps/web/npm-debug.log* - -# env files (can opt-in for committing if needed) -/apps/web/.env* -!/apps/web/.env.example - -# typescript -/apps/web/next-env.d.ts - # asdf version management .tool-versions @@ -24,11 +7,11 @@ node_modules .cursorignore .turbo -*.env +.env* +!*.env.example # cursor bun.lockb -apps/transcription/__pycache__ -apps/transcription/env -apps/bg-remover/env \ No newline at end of file +# Twiggy +.cursor/rules/file-structure.mdc diff --git a/.husky/pre-commit b/.husky/pre-commit deleted file mode 100644 index 5cebdbce9..000000000 --- a/.husky/pre-commit +++ /dev/null @@ -1 +0,0 @@ -npx ultracite format \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index a2c091a99..7672666a9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,14 +1,20 @@ { "editor.defaultFormatter": "esbenp.prettier-vscode", "[javascript][typescript][javascriptreact][typescriptreact][json][jsonc][css][graphql]": { - "editor.defaultFormatter": "biomejs.biome" + "editor.defaultFormatter": "esbenp.prettier-vscode" }, "typescript.tsdk": "node_modules/typescript/lib", "editor.formatOnSave": true, "editor.formatOnPaste": true, - "emmet.showExpandedAbbreviation": "never", "editor.codeActionsOnSave": { "source.fixAll.biome": "explicit", "source.organizeImports.biome": "explicit" + }, + "emmet.showExpandedAbbreviation": "never", + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" } } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..8ebac930d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,121 @@ +# AGENTS.md + +## Overview + +Privacy-first video editor, with a focus on simplicity and ease of use. + +## Lib vs Utils + +- `lib/` - domain logic (specific to this app) +- `utils/` - small helper utils (generic, could be copy-pasted into any other app) + +## Core Editor System + +The editor uses a **singleton EditorCore** that manages all editor state through specialized managers. + +### Architecture + +``` +EditorCore (singleton) +├── playback: PlaybackManager +├── timeline: TimelineManager +├── scene: SceneManager +├── project: ProjectManager +├── media: MediaManager +└── renderer: RendererManager +``` + +### When to Use What + +#### In React Components + +**Always use the `useEditor()` hook:** + +```typescript +import { useEditor } from '@/hooks/use-editor'; + +function MyComponent() { + const editor = useEditor(); + const tracks = editor.timeline.getTracks(); + + // Call methods + editor.timeline.addTrack({ type: 'media' }); + + // Display data (auto re-renders on changes) + return
{tracks.length} tracks
; +} +``` + +The hook: + +- Returns the singleton instance +- Subscribes to all manager changes +- Automatically re-renders when state changes + +#### Outside React Components + +**Use `EditorCore.getInstance()` directly:** + +```typescript +// In utilities, event handlers, or non-React code +import { EditorCore } from "@/core"; + +const editor = EditorCore.getInstance(); +await editor.export({ format: "mp4", quality: "high" }); +``` + +## Actions System + +Actions are the trigger layer for user-initiated operations. The single source of truth is `@/lib/actions/definitions.ts`. + +**To add a new action:** + +1. Add it to `ACTIONS` in `@/lib/actions/definitions.ts`: + +```typescript +export const ACTIONS = { + "my-action": { + description: "What the action does", + category: "editing", + defaultShortcuts: ["ctrl+m"], + }, + // ... +}; +``` + +2. Add handler in `@/hooks/use-editor-actions.ts`: + +```typescript +useActionHandler( + "my-action", + () => { + // implementation + }, + undefined, +); +``` + +**In components, use `invokeAction()` for user-triggered operations:** + +```typescript +import { invokeAction } from '@/lib/actions'; + +// Good - uses action system +const handleSplit = () => invokeAction("split-selected"); + +// Avoid - bypasses UX layer (toasts, validation feedback) +const handleSplit = () => editor.timeline.splitElements({ ... }); +``` + +Direct `editor.xxx()` calls are for internal use (commands, tests, complex multi-step operations). + +## Commands System + +Commands handle undo/redo. They live in `@/lib/commands/` organized by domain (timeline, media, scene). + +Each command extends `Command` from `@/lib/commands/base-command` and implements: + +- `execute()` - saves current state, then does the mutation +- `undo()` - restores the saved state + +Actions and commands work together: actions are "what triggered this", commands are "how to do it (and undo it)". diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 8bd9863dd..000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,168 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -OpenCut is a free, open-source video editor built with Next.js, focusing on privacy (no server processing), multi-track timeline editing, and real-time preview. The project is a monorepo using Turborepo with multiple apps including a web application, desktop app (Tauri), background remover tools, and transcription services. - -## Essential Commands - -**Development:** -```bash -# Root level development -bun dev # Start all apps in development mode -bun build # Build all apps -bun lint # Lint all code using Ultracite -bun format # Format all code using Ultracite - -# Web app specific (from apps/web/) -cd apps/web -bun run dev # Start Next.js development server with Turbopack -bun run build # Build for production -bun run lint # Run Biome linting -bun run lint:fix # Fix linting issues automatically -bun run format # Format code with Biome - -# Database operations (from apps/web/) -bun run db:generate # Generate Drizzle migrations -bun run db:migrate # Run migrations -bun run db:push:local # Push schema to local development database -bun run db:push:prod # Push schema to production database -``` - -**Testing:** -- No unified test commands are currently configured -- Individual apps may have their own test setups - -## Architecture & Key Components - -### State Management -The application uses **Zustand** for state management with separate stores for different concerns: -- **editor-store.ts**: Canvas presets, layout guides, app initialization -- **timeline-store.ts**: Timeline tracks, elements, playback state -- **media-store.ts**: Media files and asset management -- **playback-store.ts**: Video playback controls and timing -- **project-store.ts**: Project-level data and persistence -- **panel-store.ts**: UI panel visibility and layout -- **keybindings-store.ts**: Keyboard shortcut management -- **sounds-store.ts**: Audio effects and sound management -- **stickers-store.ts**: Sticker/graphics management - -### Storage System -**Multi-layer storage approach:** -- **IndexedDB**: Projects, saved sounds, and structured data -- **OPFS (Origin Private File System)**: Large media files for better performance -- **Storage Service** (`lib/storage/`): Abstraction layer managing both storage types - -### Editor Architecture -**Core editor components:** -- **Timeline Canvas**: Custom canvas-based timeline with tracks and elements -- **Preview Panel**: Real-time video preview (currently DOM-based, planned binary refactor) -- **Media Panel**: Asset management with drag-and-drop support -- **Properties Panel**: Context-sensitive element properties - -### Media Processing -- **FFmpeg Integration**: Client-side video processing using @ffmpeg/ffmpeg -- **Background Removal**: Python-based tools with multiple AI models (U2Net, SAM, Gemini) -- **Transcription**: Separate service for audio-to-text conversion - -## Development Focus Areas - -**✅ Recommended contribution areas:** -- Timeline functionality and UI improvements -- Project management features -- Performance optimizations -- Bug fixes in existing functionality -- UI/UX improvements outside preview panel -- Documentation and testing - -**⚠️ Areas to avoid (pending refactor):** -- Preview panel enhancements (fonts, stickers, effects) -- Export functionality improvements -- Preview rendering optimizations - -**Reason:** The preview system is planned for a major refactor from DOM-based rendering to binary rendering for consistency with export and better performance. - -## Code Quality Standards - -**Linting & Formatting:** -- Uses **Biome** for JavaScript/TypeScript linting and formatting -- Extends **Ultracite** configuration for strict type safety and AI-friendly code -- Comprehensive accessibility (a11y) rules enforced -- Zero configuration approach with subsecond performance - -**Key coding standards from Ultracite:** -- Strict TypeScript with no `any` types -- No React imports (uses automatic JSX runtime) -- Comprehensive accessibility requirements -- Use `for...of` instead of `Array.forEach` -- No TypeScript enums, use const objects -- Always include error handling with try-catch - -## Environment Setup - -**Required environment variables (apps/web/.env.local):** -```bash -# Database -DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut" - -# Authentication -BETTER_AUTH_SECRET="your-generated-secret-here" -BETTER_AUTH_URL="http://localhost:3000" - -# Redis -UPSTASH_REDIS_REST_URL="http://localhost:8079" -UPSTASH_REDIS_REST_TOKEN="example_token" - -# Content Management -MARBLE_WORKSPACE_KEY="workspace-key" -NEXT_PUBLIC_MARBLE_API_URL="https://api.marblecms.com" -``` - -**Docker services:** -```bash -# Start local database and Redis -docker-compose up -d -``` - -## Project Structure - -**Monorepo layout:** -- `apps/web/` - Main Next.js application -- `apps/desktop/` - Tauri desktop application -- `apps/bg-remover/` - Python background removal tools -- `apps/transcription/` - Audio transcription service -- `packages/` - Shared packages (auth, database) - -**Web app structure:** -- `src/components/` - React components organized by feature -- `src/stores/` - Zustand state management -- `src/hooks/` - Custom React hooks -- `src/lib/` - Utility functions and services -- `src/types/` - TypeScript type definitions -- `src/app/` - Next.js app router pages and API routes - -## Common Patterns - -**Error handling:** -```typescript -try { - const result = await processData(); - return { success: true, data: result }; -} catch (error) { - console.error('Operation failed:', error); - return { success: false, error: error.message }; -} -``` - -**Store usage:** -```typescript -const { tracks, addTrack, updateTrack } = useTimelineStore(); -``` - -**Media processing:** -```typescript -import { processVideo } from '@/lib/ffmpeg-utils'; -const processedVideo = await processVideo(inputFile, options); -``` \ No newline at end of file diff --git a/README.md b/README.md index 1e40f1db8..d0421dd5d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
- OpenCut Logo + OpenCut Logo

OpenCut

@@ -104,7 +104,7 @@ Before you begin, ensure you have the following installed on your system: ```bash # Database (matches docker-compose.yaml) - DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut" + DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut" # Generate a secure secret for Better Auth BETTER_AUTH_SECRET="your-generated-secret-here" @@ -160,6 +160,12 @@ See our [Contributing Guide](.github/CONTRIBUTING.md) for detailed setup instruc ## Sponsors +Thanks to [Vercel](https://vercel.com?utm_source=github-opencut&utm_campaign=oss) and [fal.ai](https://fal.ai?utm_source=github-opencut&utm_campaign=oss) for their support of open-source software. + + + Vercel OSS Program + + Powered by fal.ai diff --git a/apps/transcription/README.md b/apps/transcription/README.md deleted file mode 100644 index 07be67905..000000000 --- a/apps/transcription/README.md +++ /dev/null @@ -1,86 +0,0 @@ -Before you follow anything in this guide, please make sure you've followed the steps in the [README](../../README.md) (under "Optional: Auto-captions (Transcription) Setup"). - -Open your terminal and make sure you're in the `apps/transcription` directory. - -1. Create virtual environment - -```bash -python -m venv env -``` - -2. Activate it - -**Windows:** - -```bash -env\Scripts\activate -``` - -**macOS/Linux:** - -```bash -source env/bin/activate -``` - -> Note: if you're using VS Code/Cursor and you're seeing errors with the imports about the modules not being found, -> You might have to press CTRL + Shift + P -> Python: Select Interpreter -> Enter interpreter path -> Find -> env -> scripts -> python.exe - -3. Install libraries/packages/whatever you wanna call them - -```bash -pip install -r requirements.txt -``` - -4. Make sure you have a Modal account. If you don't: [create one](https://modal.com/) - -> If you don't know what Modal is: it allows us to process the actual audio and transcribe with Whisper by providing the infra to run Python code with a lot of RAM, generally affordable. - -5. Once you've got a Modal accoumt, run this: - -```bash -python -m modal setup -``` - -It's gonna open a browser so you can authenticate. - -6. Test it if you want to make sure it actually works: - -```bash -modal run transcription.py -``` - -6. Deploy the function! - -```bash -modal deploy transcription.py -``` - -7. Set the required secrets in Modal - -So the script we just deployed interacts with Cloudflare to do two things: - -- Download the audio (so it can be transcribed with Whisper) -- Delete the file after processing (privacy) - -To do those things, the script needs access to these environment variables: -```bash -CLOUDFLARE_ACCOUNT_ID=your-account-id -R2_ACCESS_KEY_ID=your-access-key-id -R2_SECRET_ACCESS_KEY=your-secret-access-key -R2_BUCKET_NAME=opencut-transcription -``` - -Remember, we set these earlier in `.env.local`. - -So let's do it: - - - Go to [Modal Secrets](https://modal.com/secrets/mazewinther/main) - - Click "Custom" and enter "opencut-r2-secrets" for the name. - - Now you can just click "Import .env" and copy/paste the 4 variables from your `.env.local` file. Copy and paste these only: - ```bash - CLOUDFLARE_ACCOUNT_ID=your-account-id - R2_ACCESS_KEY_ID=your-access-key-id - R2_SECRET_ACCESS_KEY=your-secret-access-key - R2_BUCKET_NAME=opencut-transcription - ``` - - Click "Done" and you should see some cool particles! \ No newline at end of file diff --git a/apps/transcription/requirements.txt b/apps/transcription/requirements.txt deleted file mode 100644 index c002e6830..000000000 --- a/apps/transcription/requirements.txt +++ /dev/null @@ -1,5 +0,0 @@ -modal -openai-whisper -boto3 -pydantic -cryptography \ No newline at end of file diff --git a/apps/transcription/transcription.py b/apps/transcription/transcription.py deleted file mode 100644 index ad55f6d47..000000000 --- a/apps/transcription/transcription.py +++ /dev/null @@ -1,143 +0,0 @@ -import modal -from pydantic import BaseModel - -app = modal.App("opencut-transcription") - -class TranscribeRequest(BaseModel): - filename: str - language: str = "auto" - decryptionKey: str = None - iv: str = None - -@app.function( - image=modal.Image.debian_slim() - .apt_install(["ffmpeg"]) - .pip_install(["openai-whisper", "boto3", "fastapi[standard]", "pydantic", "cryptography"]), - gpu="A10G", - timeout=300, # 5m - secrets=[modal.Secret.from_name("opencut-r2-secrets")] -) -@modal.fastapi_endpoint(method="POST") -def transcribe_audio(request: TranscribeRequest): - import whisper - import boto3 - import tempfile - import os - import json - - try: - filename = request.filename - language = request.language - decryption_key = request.decryptionKey - iv = request.iv - - if not filename: - return { - "error": "Missing filename parameter" - } - - # Initialize R2 client - s3_client = boto3.client( - 's3', - endpoint_url=f'https://{os.environ["CLOUDFLARE_ACCOUNT_ID"]}.r2.cloudflarestorage.com', - aws_access_key_id=os.environ["R2_ACCESS_KEY_ID"], - aws_secret_access_key=os.environ["R2_SECRET_ACCESS_KEY"], - region_name='auto' - ) - - # Create temporary file for audio - with tempfile.NamedTemporaryFile(delete=False, suffix='.wav') as temp_file: - temp_path = temp_file.name - - try: - # Download audio from R2 - s3_client.download_file( - os.environ["R2_BUCKET_NAME"], - filename, - temp_path - ) - - # If decryption key provided, decrypt the file directly (zero-knowledge) - if decryption_key and iv: - import base64 - from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes - from cryptography.hazmat.backends import default_backend - - # Read the encrypted file - with open(temp_path, 'rb') as f: - encrypted_data = f.read() - - # Decode the key and IV from base64 - key_bytes = base64.b64decode(decryption_key) - iv_bytes = base64.b64decode(iv) - - # Decrypt the data using AES-GCM - # Extract the tag (last 16 bytes) and ciphertext - tag = encrypted_data[-16:] - ciphertext = encrypted_data[:-16] - - cipher = Cipher( - algorithms.AES(key_bytes), - modes.GCM(iv_bytes, tag), - backend=default_backend() - ) - decryptor = cipher.decryptor() - decrypted_data = decryptor.update(ciphertext) + decryptor.finalize() - - # Write decrypted audio back to temp file - with open(temp_path, 'wb') as f: - f.write(decrypted_data) - - # Load Whisper model - model = whisper.load_model("base") - - # Transcribe audio - if language == "auto": - result = model.transcribe(temp_path) - else: - result = model.transcribe(temp_path, language=language.lower()) - - # Delete audio file from R2 (cleanup) - s3_client.delete_object( - Bucket=os.environ["R2_BUCKET_NAME"], - Key=filename - ) - - # Adjust segment timing - Whisper is consistently late by ~500ms - adjusted_segments = [] - for segment in result["segments"]: - adjusted_segment = segment.copy() - # Shift start/end times earlier by 500ms, don't go below 0 - adjusted_segment["start"] = max(0, segment["start"] - 0.5) - adjusted_segment["end"] = max(0.5, segment["end"] - 0.5) # Ensure duration is at least 0.5s - adjusted_segments.append(adjusted_segment) - - return { - "text": result["text"], - "segments": adjusted_segments, - "language": result["language"] - } - - finally: - # Clean up temporary file - if os.path.exists(temp_path): - os.unlink(temp_path) - - except Exception as e: - import traceback - print(f"Transcription error: {str(e)}") - print(f"Traceback: {traceback.format_exc()}") - - # Return error response that matches expected format - return { - "error": str(e), - "text": "", - "segments": [], - "language": "unknown" - } - -@app.local_entrypoint() -def main(): - # Test function - you can call this with modal run transcription.py - print("Transcription service is ready to deploy!") - print("Deploy with: modal deploy transcription.py") \ No newline at end of file diff --git a/apps/web/.env.example b/apps/web/.env.example index e77d3c5a0..b321f09af 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,33 +1,28 @@ -# Environment Variables Example +# Environment variables example # Copy this file to .env.local and update the values as needed -DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut" +# Node +NODE_ENV=development -# Better Auth -NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3000 -BETTER_AUTH_SECRET=your-secret-key-here +# Public +NEXT_PUBLIC_SITE_URL=http://localhost:3000 +NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com -# Development Environment -NODE_ENV=development +# Server +DATABASE_URL="postgresql://opencut:opencut@localhost:5432/opencut" +BETTER_AUTH_SECRET=your_better_auth_secret -# Redis UPSTASH_REDIS_REST_URL=http://localhost:8079 -UPSTASH_REDIS_REST_TOKEN=example_token +UPSTASH_REDIS_REST_TOKEN=example_token_here -# Marble Blog -MARBLE_WORKSPACE_KEY=cm6ytuq9x0000i803v0isidst # example organization key -NEXT_PUBLIC_MARBLE_API_URL=https://api.marblecms.com +MARBLE_WORKSPACE_KEY=your_workspace_key_here -# Freesound (generate at https://freesound.org/apiv2/apply/) -FREESOUND_CLIENT_ID=... -FREESOUND_API_KEY=... +FREESOUND_CLIENT_ID=your_client_id_here +FREESOUND_API_KEY=your_api_key_here -# Cloudflare R2 (for auto-captions/transcription) -# Get these from Cloudflare Dashboard > R2 > Manage R2 API tokens -CLOUDFLARE_ACCOUNT_ID=your-account-id -R2_ACCESS_KEY_ID=your-access-key-id -R2_SECRET_ACCESS_KEY=your-secret-access-key -R2_BUCKET_NAME=opencut-transcription +CLOUDFLARE_ACCOUNT_ID=your_account_id_here +R2_ACCESS_KEY_ID=your_access_key_here +R2_SECRET_ACCESS_KEY=your_secret_key_here +R2_BUCKET_NAME=opencut-transcription # whatever you named your r2 bucket -# Modal transcription endpoint (from modal deploy transcription.py) -MODAL_TRANSCRIPTION_URL=https://your-username--opencut-transcription-transcribe-audio.modal.run \ No newline at end of file +MODAL_TRANSCRIPTION_URL=your_modal_url_here \ No newline at end of file diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 36b420346..dc069c577 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -1,2 +1,8 @@ # Turborepo -.turbo \ No newline at end of file +.turbo + +# Env vars +.env* +!.env.example + +.next/ \ No newline at end of file diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile deleted file mode 100644 index 6c42e7081..000000000 --- a/apps/web/Dockerfile +++ /dev/null @@ -1,65 +0,0 @@ -FROM oven/bun:alpine AS base - -# Install dependencies and build the application -FROM base AS builder - -WORKDIR /app - -ARG FREESOUND_CLIENT_ID -ARG FREESOUND_API_KEY - -COPY package.json package.json -COPY bun.lock bun.lock -COPY turbo.json turbo.json - -COPY apps/web/package.json apps/web/package.json -COPY packages/db/package.json packages/db/package.json -COPY packages/auth/package.json packages/auth/package.json - -RUN bun install - -COPY apps/web/ apps/web/ -COPY packages/db/ packages/db/ -COPY packages/auth/ packages/auth/ - -ENV NODE_ENV production -ENV NEXT_TELEMETRY_DISABLED 1 -# Set build-time environment variables for validation -ENV DATABASE_URL="postgresql://opencut:opencutthegoat@localhost:5432/opencut" -ENV BETTER_AUTH_SECRET="build-time-secret" -ENV UPSTASH_REDIS_REST_URL="http://localhost:8079" -ENV UPSTASH_REDIS_REST_TOKEN="example_token" -ENV NEXT_PUBLIC_BETTER_AUTH_URL="http://localhost:3000" - -ENV FREESOUND_CLIENT_ID=$FREESOUND_CLIENT_ID -ENV FREESOUND_API_KEY=$FREESOUND_API_KEY - -WORKDIR /app/apps/web -RUN bun run build - -# Production image -FROM base AS runner -WORKDIR /app - -ENV NODE_ENV production -ENV NEXT_TELEMETRY_DISABLED 1 - -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs - -COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public -COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static - -RUN chown nextjs:nodejs apps - -USER nextjs - -EXPOSE 3000 - -ENV PORT=3000 -ENV HOSTNAME="0.0.0.0" - - -CMD ["bun", "apps/web/server.js"] - diff --git a/apps/web/drizzle.config.ts b/apps/web/drizzle.config.ts index a0602a74f..2d53d6b92 100644 --- a/apps/web/drizzle.config.ts +++ b/apps/web/drizzle.config.ts @@ -1,19 +1,23 @@ import type { Config } from "drizzle-kit"; import * as dotenv from "dotenv"; +import { webEnv } from "@opencut/env/web"; // Load the right env file based on environment -if (process.env.NODE_ENV === "production") { +if (webEnv.NODE_ENV === "production") { dotenv.config({ path: ".env.production" }); } else { dotenv.config({ path: ".env.local" }); } export default { - schema: "../../packages/db/src/schema.ts", + schema: "./src/schema.ts", dialect: "postgresql", + migrations: { + table: "drizzle_migrations", + }, dbCredentials: { - url: process.env.DATABASE_URL!, + url: webEnv.DATABASE_URL, }, out: "./migrations", - strict: process.env.NODE_ENV === "production", + strict: webEnv.NODE_ENV === "production", } satisfies Config; diff --git a/packages/db/migrations/0000_brainy_saracen.sql b/apps/web/migrations/0000_brainy_saracen.sql similarity index 100% rename from packages/db/migrations/0000_brainy_saracen.sql rename to apps/web/migrations/0000_brainy_saracen.sql diff --git a/apps/web/migrations/0000_hot_the_fallen.sql b/apps/web/migrations/0000_hot_the_fallen.sql deleted file mode 100644 index 321043f07..000000000 --- a/apps/web/migrations/0000_hot_the_fallen.sql +++ /dev/null @@ -1,54 +0,0 @@ -CREATE TABLE "accounts" ( - "id" text PRIMARY KEY NOT NULL, - "account_id" text NOT NULL, - "provider_id" text NOT NULL, - "user_id" text NOT NULL, - "access_token" text, - "refresh_token" text, - "id_token" text, - "access_token_expires_at" timestamp, - "refresh_token_expires_at" timestamp, - "scope" text, - "password" text, - "created_at" timestamp NOT NULL, - "updated_at" timestamp NOT NULL -); ---> statement-breakpoint -ALTER TABLE "accounts" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint -CREATE TABLE "sessions" ( - "id" text PRIMARY KEY NOT NULL, - "expires_at" timestamp NOT NULL, - "token" text NOT NULL, - "created_at" timestamp NOT NULL, - "updated_at" timestamp NOT NULL, - "ip_address" text, - "user_agent" text, - "user_id" text NOT NULL, - CONSTRAINT "sessions_token_unique" UNIQUE("token") -); ---> statement-breakpoint -ALTER TABLE "sessions" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint -CREATE TABLE "users" ( - "id" text PRIMARY KEY NOT NULL, - "name" text NOT NULL, - "email" text NOT NULL, - "email_verified" boolean NOT NULL, - "image" text, - "created_at" timestamp NOT NULL, - "updated_at" timestamp NOT NULL, - CONSTRAINT "users_email_unique" UNIQUE("email") -); ---> statement-breakpoint -ALTER TABLE "users" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint -CREATE TABLE "verifications" ( - "id" text PRIMARY KEY NOT NULL, - "identifier" text NOT NULL, - "value" text NOT NULL, - "expires_at" timestamp NOT NULL, - "created_at" timestamp, - "updated_at" timestamp -); ---> statement-breakpoint -ALTER TABLE "verifications" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint -ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/apps/web/migrations/0001_tricky_jackpot.sql b/apps/web/migrations/0001_tricky_jackpot.sql deleted file mode 100644 index 6eaeb56ed..000000000 --- a/apps/web/migrations/0001_tricky_jackpot.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE "waitlist" ( - "id" text PRIMARY KEY NOT NULL, - "email" text NOT NULL, - "created_at" timestamp NOT NULL, - CONSTRAINT "waitlist_email_unique" UNIQUE("email") -); ---> statement-breakpoint -ALTER TABLE "waitlist" ENABLE ROW LEVEL SECURITY; \ No newline at end of file diff --git a/apps/web/migrations/0002_cuddly_pretty_boy.sql b/apps/web/migrations/0002_cuddly_pretty_boy.sql deleted file mode 100644 index fc8aed796..000000000 --- a/apps/web/migrations/0002_cuddly_pretty_boy.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "users" ALTER COLUMN "email_verified" SET DEFAULT false; \ No newline at end of file diff --git a/apps/web/migrations/0003_long_polaris.sql b/apps/web/migrations/0003_long_polaris.sql deleted file mode 100644 index b2b74064d..000000000 --- a/apps/web/migrations/0003_long_polaris.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE TABLE "export_waitlist" ( - "id" text PRIMARY KEY NOT NULL, - "email" text NOT NULL, - "created_at" timestamp NOT NULL, - "updated_at" timestamp NOT NULL, - CONSTRAINT "export_waitlist_email_unique" UNIQUE("email") -); ---> statement-breakpoint -ALTER TABLE "export_waitlist" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint -DROP TABLE "waitlist" CASCADE; \ No newline at end of file diff --git a/apps/web/migrations/meta/0000_snapshot.json b/apps/web/migrations/meta/0000_snapshot.json index 2da63d5c1..8d505274a 100644 --- a/apps/web/migrations/meta/0000_snapshot.json +++ b/apps/web/migrations/meta/0000_snapshot.json @@ -1,5 +1,5 @@ { - "id": "4440fb90-cf0e-4cb2-afad-08250ce3dc1e", + "id": "33a6742f-89da-4ac5-958f-421aa1cf9bd6", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", @@ -291,6 +291,43 @@ "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": {}, diff --git a/apps/web/migrations/meta/0001_snapshot.json b/apps/web/migrations/meta/0001_snapshot.json deleted file mode 100644 index 008540ad0..000000000 --- a/apps/web/migrations/meta/0001_snapshot.json +++ /dev/null @@ -1,344 +0,0 @@ -{ - "id": "b7d920ca-6dd0-430f-8ee6-1d38fdf3e80f", - "prevId": "4440fb90-cf0e-4cb2-afad-08250ce3dc1e", - "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": {} - } -} diff --git a/apps/web/migrations/meta/0002_snapshot.json b/apps/web/migrations/meta/0002_snapshot.json deleted file mode 100644 index 425125645..000000000 --- a/apps/web/migrations/meta/0002_snapshot.json +++ /dev/null @@ -1,345 +0,0 @@ -{ - "id": "1b9148ed-497b-4e53-8cf6-f556c8fe7f7f", - "prevId": "b7d920ca-6dd0-430f-8ee6-1d38fdf3e80f", - "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, - "default": false - }, - "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": {} - } -} diff --git a/apps/web/migrations/meta/0003_snapshot.json b/apps/web/migrations/meta/0003_snapshot.json deleted file mode 100644 index 2c5d986ea..000000000 --- a/apps/web/migrations/meta/0003_snapshot.json +++ /dev/null @@ -1,365 +0,0 @@ -{ - "id": "71b84015-78f8-4d07-9115-8d56ea550459", - "prevId": "1b9148ed-497b-4e53-8cf6-f556c8fe7f7f", - "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.export_waitlist": { - "name": "export_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 - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "export_waitlist_email_unique": { - "name": "export_waitlist_email_unique", - "nullsNotDistinct": false, - "columns": [ - "email" - ] - } - }, - "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, - "default": false - }, - "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 - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} \ No newline at end of file diff --git a/apps/web/migrations/meta/_journal.json b/apps/web/migrations/meta/_journal.json index c172952eb..c7d30f167 100644 --- a/apps/web/migrations/meta/_journal.json +++ b/apps/web/migrations/meta/_journal.json @@ -5,30 +5,9 @@ { "idx": 0, "version": "7", - "when": 1750581188229, - "tag": "0000_hot_the_fallen", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1750689835736, - "tag": "0001_tricky_jackpot", - "breakpoints": true - }, - { - "idx": 2, - "version": "7", - "when": 1752569400809, - "tag": "0002_cuddly_pretty_boy", - "breakpoints": true - }, - { - "idx": 3, - "version": "7", - "when": 1755377469662, - "tag": "0003_long_polaris", + "when": 1750753385927, + "tag": "0000_brainy_saracen", "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 000000000..c4b7818fb --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/dev/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/package.json b/apps/web/package.json index 25dc9aee0..aa752a819 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,5 +1,5 @@ { - "name": "opencut", + "name": "@opencut/web", "version": "0.1.0", "private": true, "packageManager": "bun@1.2.18", @@ -21,13 +21,18 @@ "@ffmpeg/util": "^0.12.2", "@hello-pangea/dnd": "^18.0.1", "@hookform/resolvers": "^3.9.1", - "@opencut/auth": "workspace:*", - "@opencut/db": "workspace:*", - "@radix-ui/react-separator": "^1.1.7", - "@t3-oss/env-core": "^0.13.8", - "@t3-oss/env-nextjs": "^0.13.8", - "@upstash/ratelimit": "^2.0.5", - "@upstash/redis": "^1.35.0", + "@hugeicons/core-free-icons": "^3.1.1", + "@hugeicons/react": "^1.1.4", + "@huggingface/transformers": "^3.8.1", + "@opencut/env": "workspace:*", + "@opencut/ui": "workspace:*", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-tooltip": "^1.2.8", + "@upstash/ratelimit": "^2.0.6", + "@upstash/redis": "^1.35.4", "@vercel/analytics": "^1.4.1", "aws4fetch": "^1.0.20", "better-auth": "^1.2.7", @@ -36,18 +41,19 @@ "clsx": "^2.1.1", "cmdk": "^1.0.0", "dayjs": "^1.11.13", - "dotenv": "^16.5.0", "drizzle-orm": "^0.44.2", "embla-carousel-react": "^8.5.1", + "eventemitter3": "^5.0.1", "feed": "^5.1.0", - "framer-motion": "^11.13.1", "input-otp": "^1.4.1", - "lucide-react": "^0.468.0", + "lucide-react": "^0.562.0", + "mediabunny": "^1.29.1", "motion": "^12.18.1", "nanoid": "^5.1.5", - "next": "^15.5.7", + "next": "16.1.3", "next-themes": "^0.4.4", "pg": "^8.16.2", + "postgres": "^3.4.5", "radix-ui": "^1.4.2", "react": "^18.2.0", "react-day-picker": "^8.10.1", @@ -67,7 +73,9 @@ "tailwind-merge": "^2.5.5", "tailwindcss-animate": "^1.0.7", "unified": "^11.0.5", + "use-deep-compare-effect": "^1.8.1", "vaul": "^1.1.1", + "wavesurfer.js": "^7.9.8", "zod": "^3.25.67", "zustand": "^5.0.2" }, @@ -81,6 +89,7 @@ "@types/react-dom": "^18.2.18", "cross-env": "^7.0.3", "drizzle-kit": "^0.31.4", + "dotenv": "^16.5.0", "postcss": "^8", "tailwindcss": "^4.1.11", "tsx": "^4.7.1", diff --git a/apps/web/public/frame.svg b/apps/web/public/frame.svg deleted file mode 100644 index 4dac68e9f..000000000 --- a/apps/web/public/frame.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/apps/web/public/logo.png b/apps/web/public/logo.png deleted file mode 100644 index fc9e7902c..000000000 Binary files a/apps/web/public/logo.png and /dev/null differ diff --git a/apps/web/public/logos/opencut/1k/logo-black-with-text.png b/apps/web/public/logos/opencut/1k/logo-black-with-text.png new file mode 100644 index 000000000..08ceb702e Binary files /dev/null and b/apps/web/public/logos/opencut/1k/logo-black-with-text.png differ diff --git a/apps/web/public/logos/opencut/1k/logo-black.png b/apps/web/public/logos/opencut/1k/logo-black.png new file mode 100644 index 000000000..6b21beb92 Binary files /dev/null and b/apps/web/public/logos/opencut/1k/logo-black.png differ diff --git a/apps/web/public/logos/opencut/1k/logo-white-with-text.png b/apps/web/public/logos/opencut/1k/logo-white-with-text.png new file mode 100644 index 000000000..a31510605 Binary files /dev/null and b/apps/web/public/logos/opencut/1k/logo-white-with-text.png differ diff --git a/apps/web/public/logos/opencut/1k/logo-white.png b/apps/web/public/logos/opencut/1k/logo-white.png new file mode 100644 index 000000000..f4f6ae4c5 Binary files /dev/null and b/apps/web/public/logos/opencut/1k/logo-white.png differ diff --git a/apps/web/public/logos/opencut/2k/logo-black-with-text.png b/apps/web/public/logos/opencut/2k/logo-black-with-text.png new file mode 100644 index 000000000..7493bed63 Binary files /dev/null and b/apps/web/public/logos/opencut/2k/logo-black-with-text.png differ diff --git a/apps/web/public/logos/opencut/2k/logo-black.png b/apps/web/public/logos/opencut/2k/logo-black.png new file mode 100644 index 000000000..1336d1720 Binary files /dev/null and b/apps/web/public/logos/opencut/2k/logo-black.png differ diff --git a/apps/web/public/logos/opencut/2k/logo-white-with-text.png b/apps/web/public/logos/opencut/2k/logo-white-with-text.png new file mode 100644 index 000000000..29bf0e6f4 Binary files /dev/null and b/apps/web/public/logos/opencut/2k/logo-white-with-text.png differ diff --git a/apps/web/public/logos/opencut/2k/logo-white.png b/apps/web/public/logos/opencut/2k/logo-white.png new file mode 100644 index 000000000..9ee83d3c1 Binary files /dev/null and b/apps/web/public/logos/opencut/2k/logo-white.png differ diff --git a/apps/web/public/logos/opencut/4k/logo-black-with-text.png b/apps/web/public/logos/opencut/4k/logo-black-with-text.png new file mode 100644 index 000000000..edaf5358b Binary files /dev/null and b/apps/web/public/logos/opencut/4k/logo-black-with-text.png differ diff --git a/apps/web/public/logos/opencut/4k/logo-black.png b/apps/web/public/logos/opencut/4k/logo-black.png new file mode 100644 index 000000000..b5f0ff13c Binary files /dev/null and b/apps/web/public/logos/opencut/4k/logo-black.png differ diff --git a/apps/web/public/logos/opencut/4k/logo-white-with-text.png b/apps/web/public/logos/opencut/4k/logo-white-with-text.png new file mode 100644 index 000000000..90596e872 Binary files /dev/null and b/apps/web/public/logos/opencut/4k/logo-white-with-text.png differ diff --git a/apps/web/public/logos/opencut/4k/logo-white.png b/apps/web/public/logos/opencut/4k/logo-white.png new file mode 100644 index 000000000..794e360b4 Binary files /dev/null and b/apps/web/public/logos/opencut/4k/logo-white.png differ diff --git a/apps/web/public/logo.svg b/apps/web/public/logos/opencut/svg/logo.svg similarity index 100% rename from apps/web/public/logo.svg rename to apps/web/public/logos/opencut/svg/logo.svg diff --git a/apps/web/public/logos/others/fal.svg b/apps/web/public/logos/others/fal.svg new file mode 100644 index 000000000..abc565134 --- /dev/null +++ b/apps/web/public/logos/others/fal.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/logos/others/vercel.svg b/apps/web/public/logos/others/vercel.svg new file mode 100644 index 000000000..824018a39 --- /dev/null +++ b/apps/web/public/logos/others/vercel.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx deleted file mode 100644 index f28cd1914..000000000 --- a/apps/web/src/app/(auth)/login/page.tsx +++ /dev/null @@ -1,146 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { memo, Suspense } from "react"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Separator } from "@/components/ui/separator"; -import Link from "next/link"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { ArrowLeft, Loader2 } from "lucide-react"; -import { GoogleIcon } from "@/components/icons"; -import { useLogin } from "@/hooks/auth/useLogin"; - -const LoginPage = () => { - const router = useRouter(); - const { - email, - setEmail, - password, - setPassword, - error, - isAnyLoading, - isEmailLoading, - isGoogleLoading, - handleLogin, - handleGoogleLogin, - } = useLogin(); - - return ( -
- - - - Welcome back - - Sign in to your account to continue - - - - - -
- } - > -
- {error && ( - - Error - {error} - - )} - - -
-
- -
-
- - Or continue with - -
-
-
-
- - setEmail(e.target.value)} - disabled={isAnyLoading} - className="h-11" - /> -
-
- - setPassword(e.target.value)} - disabled={isAnyLoading} - className="h-11" - /> -
- -
-
-
- Don't have an account?{" "} - - Sign up - -
- - - - - ); -}; - -export default memo(LoginPage); diff --git a/apps/web/src/app/(auth)/signup/page.tsx b/apps/web/src/app/(auth)/signup/page.tsx deleted file mode 100644 index c3a0f8994..000000000 --- a/apps/web/src/app/(auth)/signup/page.tsx +++ /dev/null @@ -1,162 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { memo, Suspense } from "react"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Separator } from "@/components/ui/separator"; -import Link from "next/link"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { ArrowLeft, Loader2 } from "lucide-react"; -import { GoogleIcon } from "@/components/icons"; -import { useSignUp } from "@/hooks/auth/useSignUp"; - -const SignUpPage = () => { - const router = useRouter(); - const { - name, - setName, - email, - setEmail, - password, - setPassword, - error, - isAnyLoading, - isEmailLoading, - isGoogleLoading, - handleSignUp, - handleGoogleSignUp, - } = useSignUp(); - - return ( -
- - - - - Create your account - - - Get started with your free account today - - - - - -
- } - > -
- {error && ( - - Error - {error} - - )} - -
-
- -
-
- - Or continue with - -
-
-
-
- - setName(e.target.value)} - disabled={isAnyLoading} - className="h-11" - /> -
-
- - setEmail(e.target.value)} - disabled={isAnyLoading} - className="h-11" - /> -
-
- - setPassword(e.target.value)} - disabled={isAnyLoading} - className="h-11" - /> -
- -
-
-
- Already have an account?{" "} - - Sign in - -
- - - - - ); -}; - -export default memo(SignUpPage); diff --git a/apps/web/src/app/animation/page.tsx b/apps/web/src/app/animation/page.tsx deleted file mode 100644 index 37be1ef09..000000000 --- a/apps/web/src/app/animation/page.tsx +++ /dev/null @@ -1,24 +0,0 @@ -"use client"; - -import { InputWithBack } from "@/components/ui/input-with-back"; -import { useState } from "react"; - -export default function AnimationPage() { - const [isExpanded, setIsExpanded] = useState(false); - - return ( -
-
- -
-
-

setIsExpanded(!isExpanded)} - className="cursor-pointer hover:opacity-75 transition-opacity" - > - {isExpanded ? "Collapse" : "Expand"} -

-
-
- ); -} diff --git a/apps/web/src/app/api/auth/[...all]/route.ts b/apps/web/src/app/api/auth/[...all]/route.ts index 5deef2043..e17d0cce3 100644 --- a/apps/web/src/app/api/auth/[...all]/route.ts +++ b/apps/web/src/app/api/auth/[...all]/route.ts @@ -1,4 +1,4 @@ -import { auth } from "@opencut/auth"; +import { auth } from "@/lib/auth/server"; import { toNextJsHandler } from "better-auth/next-js"; export const { POST, GET } = toNextJsHandler(auth); diff --git a/apps/web/src/app/api/get-upload-url/route.ts b/apps/web/src/app/api/get-upload-url/route.ts deleted file mode 100644 index dc5b7328f..000000000 --- a/apps/web/src/app/api/get-upload-url/route.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { AwsClient } from "aws4fetch"; -import { nanoid } from "nanoid"; -import { env } from "@/env"; -import { baseRateLimit } from "@/lib/rate-limit"; -import { isTranscriptionConfigured } from "@/lib/transcription-utils"; - -const uploadRequestSchema = z.object({ - fileExtension: z.enum(["wav", "mp3", "m4a", "flac"], { - errorMap: () => ({ - message: "File extension must be wav, mp3, m4a, or flac", - }), - }), -}); - -const apiResponseSchema = z.object({ - uploadUrl: z.string().url(), - fileName: z.string().min(1), -}); - -export async function POST(request: NextRequest) { - try { - // Rate limiting - const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; - const { success } = await baseRateLimit.limit(ip); - - if (!success) { - return NextResponse.json({ error: "Too many requests" }, { status: 429 }); - } - - // Check transcription configuration - const transcriptionCheck = isTranscriptionConfigured(); - if (!transcriptionCheck.configured) { - console.error( - "Missing environment variables:", - JSON.stringify(transcriptionCheck.missingVars) - ); - - return NextResponse.json( - { - error: "Transcription not configured", - message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`, - }, - { status: 503 } - ); - } - - // Parse and validate request body - const rawBody = await request.json().catch(() => null); - if (!rawBody) { - return NextResponse.json( - { error: "Invalid JSON in request body" }, - { status: 400 } - ); - } - - const validationResult = uploadRequestSchema.safeParse(rawBody); - if (!validationResult.success) { - return NextResponse.json( - { - error: "Invalid request parameters", - details: validationResult.error.flatten().fieldErrors, - }, - { status: 400 } - ); - } - - const { fileExtension } = validationResult.data; - - // Initialize R2 client - const client = new AwsClient({ - accessKeyId: env.R2_ACCESS_KEY_ID, - secretAccessKey: env.R2_SECRET_ACCESS_KEY, - }); - - // Generate unique filename with timestamp - const timestamp = Date.now(); - const fileName = `audio/${timestamp}-${nanoid()}.${fileExtension}`; - - // Create presigned URL - const url = new URL( - `https://${env.R2_BUCKET_NAME}.${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com/${fileName}` - ); - - url.searchParams.set("X-Amz-Expires", "3600"); // 1 hour expiry - - const signed = await client.sign(new Request(url, { method: "PUT" }), { - aws: { signQuery: true }, - }); - - if (!signed.url) { - throw new Error("Failed to generate presigned URL"); - } - - // Prepare and validate response - const responseData = { - uploadUrl: signed.url, - fileName, - }; - - const responseValidation = apiResponseSchema.safeParse(responseData); - if (!responseValidation.success) { - console.error( - "Invalid API response structure:", - responseValidation.error - ); - return NextResponse.json( - { error: "Internal response formatting error" }, - { status: 500 } - ); - } - - return NextResponse.json(responseValidation.data); - } catch (error) { - console.error("Error generating upload URL:", error); - return NextResponse.json( - { - error: "Failed to generate upload URL", - message: - error instanceof Error - ? error.message - : "An unexpected error occurred", - }, - { status: 500 } - ); - } -} diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts index ecb9ce042..edb40a0dd 100644 --- a/apps/web/src/app/api/health/route.ts +++ b/apps/web/src/app/api/health/route.ts @@ -1,5 +1,3 @@ -import { NextRequest } from "next/server"; - -export async function GET(request: NextRequest) { - return new Response("OK", { status: 200 }); +export async function GET() { + return new Response("OK", { status: 200 }); } diff --git a/apps/web/src/app/api/sounds/search/route.ts b/apps/web/src/app/api/sounds/search/route.ts index c89bc76c6..3070d152d 100644 --- a/apps/web/src/app/api/sounds/search/route.ts +++ b/apps/web/src/app/api/sounds/search/route.ts @@ -1,265 +1,280 @@ -import { NextRequest, NextResponse } from "next/server"; +import { webEnv } from "@opencut/env/web"; +import { type NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { env } from "@/env"; -import { baseRateLimit } from "@/lib/rate-limit"; +import { checkRateLimit } from "@/lib/rate-limit"; const searchParamsSchema = z.object({ - q: z.string().max(500, "Query too long").optional(), - type: z.enum(["songs", "effects"]).optional(), - page: z.coerce.number().int().min(1).max(1000).default(1), - page_size: z.coerce.number().int().min(1).max(150).default(20), - sort: z - .enum(["downloads", "rating", "created", "score"]) - .default("downloads"), - min_rating: z.coerce.number().min(0).max(5).default(3), - commercial_only: z.coerce.boolean().default(true), + q: z.string().max(500, "Query too long").optional(), + type: z.enum(["songs", "effects"]).optional(), + page: z.coerce.number().int().min(1).max(1000).default(1), + page_size: z.coerce.number().int().min(1).max(150).default(20), + sort: z + .enum(["downloads", "rating", "created", "score"]) + .default("downloads"), + min_rating: z.coerce.number().min(0).max(5).default(3), + commercial_only: z.coerce.boolean().default(true), }); const freesoundResultSchema = z.object({ - id: z.number(), - name: z.string(), - description: z.string(), - url: z.string().url(), - previews: z - .object({ - "preview-hq-mp3": z.string().url(), - "preview-lq-mp3": z.string().url(), - "preview-hq-ogg": z.string().url(), - "preview-lq-ogg": z.string().url(), - }) - .optional(), - download: z.string().url().optional(), - duration: z.number(), - filesize: z.number(), - type: z.string(), - channels: z.number(), - bitrate: z.number(), - bitdepth: z.number(), - samplerate: z.number(), - username: z.string(), - tags: z.array(z.string()), - license: z.string(), - created: z.string(), - num_downloads: z.number().optional(), - avg_rating: z.number().optional(), - num_ratings: z.number().optional(), + id: z.number(), + name: z.string(), + description: z.string(), + url: z.string().url(), + previews: z + .object({ + "preview-hq-mp3": z.string().url(), + "preview-lq-mp3": z.string().url(), + "preview-hq-ogg": z.string().url(), + "preview-lq-ogg": z.string().url(), + }) + .optional(), + download: z.string().url().optional(), + duration: z.number(), + filesize: z.number(), + type: z.string(), + channels: z.number(), + bitrate: z.number(), + bitdepth: z.number(), + samplerate: z.number(), + username: z.string(), + tags: z.array(z.string()), + license: z.string(), + created: z.string(), + num_downloads: z.number().optional(), + avg_rating: z.number().optional(), + num_ratings: z.number().optional(), }); const freesoundResponseSchema = z.object({ - count: z.number(), - next: z.string().url().nullable(), - previous: z.string().url().nullable(), - results: z.array(freesoundResultSchema), + count: z.number(), + next: z.string().url().nullable(), + previous: z.string().url().nullable(), + results: z.array(freesoundResultSchema), }); const transformedResultSchema = z.object({ - id: z.number(), - name: z.string(), - description: z.string(), - url: z.string(), - previewUrl: z.string().optional(), - downloadUrl: z.string().optional(), - duration: z.number(), - filesize: z.number(), - type: z.string(), - channels: z.number(), - bitrate: z.number(), - bitdepth: z.number(), - samplerate: z.number(), - username: z.string(), - tags: z.array(z.string()), - license: z.string(), - created: z.string(), - downloads: z.number().optional(), - rating: z.number().optional(), - ratingCount: z.number().optional(), + id: z.number(), + name: z.string(), + description: z.string(), + url: z.string(), + previewUrl: z.string().optional(), + downloadUrl: z.string().optional(), + duration: z.number(), + filesize: z.number(), + type: z.string(), + channels: z.number(), + bitrate: z.number(), + bitdepth: z.number(), + samplerate: z.number(), + username: z.string(), + tags: z.array(z.string()), + license: z.string(), + created: z.string(), + downloads: z.number().optional(), + rating: z.number().optional(), + ratingCount: z.number().optional(), }); const apiResponseSchema = z.object({ - count: z.number(), - next: z.string().nullable(), - previous: z.string().nullable(), - results: z.array(transformedResultSchema), - query: z.string().optional(), - type: z.string(), - page: z.number(), - pageSize: z.number(), - sort: z.string(), - minRating: z.number().optional(), + count: z.number(), + next: z.string().nullable(), + previous: z.string().nullable(), + results: z.array(transformedResultSchema), + query: z.string().optional(), + type: z.string(), + page: z.number(), + pageSize: z.number(), + sort: z.string(), + minRating: z.number().optional(), }); -export async function GET(request: NextRequest) { - try { - const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; - const { success } = await baseRateLimit.limit(ip); +function buildSortParameter({ query, sort }: { query?: string; sort: string }) { + if (!query) return `${sort}_desc`; + return sort === "score" ? "score" : `${sort}_desc`; +} - if (!success) { - return NextResponse.json({ error: "Too many requests" }, { status: 429 }); - } +function applyEffectsFilters({ + params, + min_rating, + commercial_only, +}: { + params: URLSearchParams; + min_rating: number; + commercial_only: boolean; +}) { + params.append("filter", "duration:[* TO 30.0]"); + params.append("filter", `avg_rating:[${min_rating} TO *]`); - const { searchParams } = new URL(request.url); + if (commercial_only) { + params.append( + "filter", + 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")', + ); + } + + params.append( + "filter", + "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion", + ); +} - const validationResult = searchParamsSchema.safeParse({ - q: searchParams.get("q") || undefined, - type: searchParams.get("type") || undefined, - page: searchParams.get("page") || undefined, - page_size: searchParams.get("page_size") || undefined, - sort: searchParams.get("sort") || undefined, - min_rating: searchParams.get("min_rating") || undefined, - }); +function transformFreesoundResult( + result: z.infer, +) { + return { + id: result.id, + name: result.name, + description: result.description, + url: result.url, + previewUrl: + result.previews?.["preview-hq-mp3"] || + result.previews?.["preview-lq-mp3"], + downloadUrl: result.download, + duration: result.duration, + filesize: result.filesize, + type: result.type, + channels: result.channels, + bitrate: result.bitrate, + bitdepth: result.bitdepth, + samplerate: result.samplerate, + username: result.username, + tags: result.tags, + license: result.license, + created: result.created, + downloads: result.num_downloads || 0, + rating: result.avg_rating || 0, + ratingCount: result.num_ratings || 0, + }; +} + +export async function GET(request: NextRequest) { + try { + const { limited } = await checkRateLimit({ request }); + if (limited) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } - if (!validationResult.success) { - return NextResponse.json( - { - error: "Invalid parameters", - details: validationResult.error.flatten().fieldErrors, - }, - { status: 400 } - ); - } + const { searchParams } = new URL(request.url); - const { - q: query, - type, - page, - page_size: pageSize, - sort, - min_rating, - commercial_only, - } = validationResult.data; + const validationResult = searchParamsSchema.safeParse({ + q: searchParams.get("q") || undefined, + type: searchParams.get("type") || undefined, + page: searchParams.get("page") || undefined, + page_size: searchParams.get("page_size") || undefined, + sort: searchParams.get("sort") || undefined, + min_rating: searchParams.get("min_rating") || undefined, + }); - if (type === "songs") { - return NextResponse.json( - { - error: "Songs are not available yet", - message: - "Song search functionality is coming soon. Try searching for sound effects instead.", - }, - { status: 501 } - ); - } + if (!validationResult.success) { + return NextResponse.json( + { + error: "Invalid parameters", + details: validationResult.error.flatten().fieldErrors, + }, + { status: 400 }, + ); + } - const baseUrl = "https://freesound.org/apiv2/search/text/"; + const { + q: query, + type, + page, + page_size: pageSize, + sort, + min_rating, + commercial_only, + } = validationResult.data; - // Use score sorting for search queries, downloads for top sounds - const sortParam = query - ? sort === "score" - ? "score" - : `${sort}_desc` - : `${sort}_desc`; + if (type === "songs") { + return NextResponse.json( + { + error: "Songs are not available yet", + message: + "Song search functionality is coming soon. Try searching for sound effects instead.", + }, + { status: 501 }, + ); + } - const params = new URLSearchParams({ - query: query || "", - token: env.FREESOUND_API_KEY, - page: page.toString(), - page_size: pageSize.toString(), - sort: sortParam, - fields: - "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings", - }); + const baseUrl = "https://freesound.org/apiv2/search/text/"; - // Always apply sound effect filters (since we're primarily a sound effects search) - if (type === "effects" || !type) { - params.append("filter", "duration:[* TO 30.0]"); - params.append("filter", `avg_rating:[${min_rating} TO *]`); + const sortParam = buildSortParameter({ query, sort }); - // Filter by license if commercial_only is true - if (commercial_only) { - params.append( - "filter", - 'license:("Attribution" OR "Creative Commons 0" OR "Attribution Noncommercial" OR "Attribution Commercial")' - ); - } + const params = new URLSearchParams({ + query: query || "", + token: webEnv.FREESOUND_API_KEY, + page: page.toString(), + page_size: pageSize.toString(), + sort: sortParam, + fields: + "id,name,description,url,previews,download,duration,filesize,type,channels,bitrate,bitdepth,samplerate,username,tags,license,created,num_downloads,avg_rating,num_ratings", + }); - params.append( - "filter", - "tag:sound-effect OR tag:sfx OR tag:foley OR tag:ambient OR tag:nature OR tag:mechanical OR tag:electronic OR tag:impact OR tag:whoosh OR tag:explosion" - ); - } + const isEffectsSearch = type === "effects" || !type; + if (isEffectsSearch) { + applyEffectsFilters({ params, min_rating, commercial_only }); + } - const response = await fetch(`${baseUrl}?${params.toString()}`); + const response = await fetch(`${baseUrl}?${params.toString()}`); - if (!response.ok) { - const errorText = await response.text(); - console.error("Freesound API error:", response.status, errorText); - return NextResponse.json( - { error: "Failed to search sounds" }, - { status: response.status } - ); - } + if (!response.ok) { + const errorText = await response.text(); + console.error("Freesound API error:", response.status, errorText); + return NextResponse.json( + { error: "Failed to search sounds" }, + { status: response.status }, + ); + } - const rawData = await response.json(); + const rawData = await response.json(); - const freesoundValidation = freesoundResponseSchema.safeParse(rawData); - if (!freesoundValidation.success) { - console.error( - "Invalid Freesound API response:", - freesoundValidation.error - ); - return NextResponse.json( - { error: "Invalid response from Freesound API" }, - { status: 502 } - ); - } + const freesoundValidation = freesoundResponseSchema.safeParse(rawData); + if (!freesoundValidation.success) { + console.error( + "Invalid Freesound API response:", + freesoundValidation.error, + ); + return NextResponse.json( + { error: "Invalid response from Freesound API" }, + { status: 502 }, + ); + } - const data = freesoundValidation.data; + const data = freesoundValidation.data; - const transformedResults = data.results.map((result) => ({ - id: result.id, - name: result.name, - description: result.description, - url: result.url, - previewUrl: - result.previews?.["preview-hq-mp3"] || - result.previews?.["preview-lq-mp3"], - downloadUrl: result.download, - duration: result.duration, - filesize: result.filesize, - type: result.type, - channels: result.channels, - bitrate: result.bitrate, - bitdepth: result.bitdepth, - samplerate: result.samplerate, - username: result.username, - tags: result.tags, - license: result.license, - created: result.created, - downloads: result.num_downloads || 0, - rating: result.avg_rating || 0, - ratingCount: result.num_ratings || 0, - })); + const transformedResults = data.results.map(transformFreesoundResult); - const responseData = { - count: data.count, - next: data.next, - previous: data.previous, - results: transformedResults, - query: query || "", - type: type || "effects", - page, - pageSize, - sort, - minRating: min_rating, - }; + const responseData = { + count: data.count, + next: data.next, + previous: data.previous, + results: transformedResults, + query: query || "", + type: type || "effects", + page, + pageSize, + sort, + minRating: min_rating, + }; - const responseValidation = apiResponseSchema.safeParse(responseData); - if (!responseValidation.success) { - console.error( - "Invalid API response structure:", - responseValidation.error - ); - return NextResponse.json( - { error: "Internal response formatting error" }, - { status: 500 } - ); - } + const responseValidation = apiResponseSchema.safeParse(responseData); + if (!responseValidation.success) { + console.error( + "Invalid API response structure:", + responseValidation.error, + ); + return NextResponse.json( + { error: "Internal response formatting error" }, + { status: 500 }, + ); + } - return NextResponse.json(responseValidation.data); - } catch (error) { - console.error("Error searching sounds:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } + return NextResponse.json(responseValidation.data); + } catch (error) { + console.error("Error searching sounds:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } } diff --git a/apps/web/src/app/api/transcribe/route.ts b/apps/web/src/app/api/transcribe/route.ts deleted file mode 100644 index 9a497f65e..000000000 --- a/apps/web/src/app/api/transcribe/route.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { z } from "zod"; -import { env } from "@/env"; -import { baseRateLimit } from "@/lib/rate-limit"; -import { isTranscriptionConfigured } from "@/lib/transcription-utils"; - -const transcribeRequestSchema = z.object({ - filename: z.string().min(1, "Filename is required"), - language: z.string().optional().default("auto"), - decryptionKey: z.string().min(1, "Decryption key is required").optional(), - iv: z.string().min(1, "IV is required").optional(), -}); - -const modalResponseSchema = z.object({ - text: z.string(), - segments: z.array( - z.object({ - id: z.number(), - seek: z.number(), - start: z.number(), - end: z.number(), - text: z.string(), - tokens: z.array(z.number()), - temperature: z.number(), - avg_logprob: z.number(), - compression_ratio: z.number(), - no_speech_prob: z.number(), - }) - ), - language: z.string(), -}); - -const apiResponseSchema = z.object({ - text: z.string(), - segments: z.array( - z.object({ - id: z.number(), - seek: z.number(), - start: z.number(), - end: z.number(), - text: z.string(), - tokens: z.array(z.number()), - temperature: z.number(), - avg_logprob: z.number(), - compression_ratio: z.number(), - no_speech_prob: z.number(), - }) - ), - language: z.string(), -}); - -export async function POST(request: NextRequest) { - try { - // Rate limiting - const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; - const { success } = await baseRateLimit.limit(ip); - const origin = request.headers.get("origin"); - - if (!success) { - return NextResponse.json({ error: "Too many requests" }, { status: 429 }); - } - - // Check transcription configuration - const transcriptionCheck = isTranscriptionConfigured(); - if (!transcriptionCheck.configured) { - console.error( - "Missing environment variables:", - JSON.stringify(transcriptionCheck.missingVars) - ); - - return NextResponse.json( - { - error: "Transcription not configured", - message: `Auto-captions require environment variables: ${transcriptionCheck.missingVars.join(", ")}. Check README for setup instructions.`, - }, - { status: 503 } - ); - } - - // Parse and validate request body - const rawBody = await request.json().catch(() => null); - if (!rawBody) { - return NextResponse.json( - { error: "Invalid JSON in request body" }, - { status: 400 } - ); - } - - const validationResult = transcribeRequestSchema.safeParse(rawBody); - if (!validationResult.success) { - return NextResponse.json( - { - error: "Invalid request parameters", - details: validationResult.error.flatten().fieldErrors, - }, - { status: 400 } - ); - } - - const { filename, language, decryptionKey, iv } = validationResult.data; - - // Prepare request body for Modal - const modalRequestBody: any = { - filename, - language, - }; - - // Add encryption parameters if provided (zero-knowledge) - if (decryptionKey && iv) { - modalRequestBody.decryptionKey = decryptionKey; - modalRequestBody.iv = iv; - } - - // Call Modal transcription service - const response = await fetch(env.MODAL_TRANSCRIPTION_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(modalRequestBody), - }); - - if (!response.ok) { - const errorText = await response.text(); - console.error("Modal API error:", response.status, errorText); - - let errorMessage = "Transcription service unavailable"; - try { - const errorData = JSON.parse(errorText); - errorMessage = errorData.error || errorMessage; - } catch { - // Use default message if parsing fails - } - - return NextResponse.json( - { - error: errorMessage, - message: "Failed to process transcription request", - }, - { status: response.status >= 500 ? 502 : response.status } - ); - } - - const rawResult = await response.json(); - console.log("Raw Modal response:", JSON.stringify(rawResult, null, 2)); - - // Validate Modal response - const modalValidation = modalResponseSchema.safeParse(rawResult); - if (!modalValidation.success) { - console.error("Invalid Modal API response:", modalValidation.error); - return NextResponse.json( - { error: "Invalid response from transcription service" }, - { status: 502 } - ); - } - - const result = modalValidation.data; - - // Prepare and validate API response - const responseData = { - text: result.text, - segments: result.segments, - language: result.language, - }; - - const responseValidation = apiResponseSchema.safeParse(responseData); - if (!responseValidation.success) { - console.error( - "Invalid API response structure:", - responseValidation.error - ); - return NextResponse.json( - { error: "Internal response formatting error" }, - { status: 500 } - ); - } - - return NextResponse.json(responseValidation.data); - } catch (error) { - console.error("Transcription API error:", error); - return NextResponse.json( - { - error: "Internal server error", - message: "An unexpected error occurred during transcription", - }, - { status: 500 } - ); - } -} diff --git a/apps/web/src/app/api/waitlist/export/route.ts b/apps/web/src/app/api/waitlist/export/route.ts deleted file mode 100644 index 0200e255e..000000000 --- a/apps/web/src/app/api/waitlist/export/route.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { baseRateLimit } from "@/lib/rate-limit"; -import { db, exportWaitlist, eq } from "@opencut/db"; -import { randomUUID } from "crypto"; -import { - exportWaitlistSchema, - exportWaitlistResponseSchema, -} from "@/lib/schemas/waitlist"; - -const requestSchema = exportWaitlistSchema; -const responseSchema = exportWaitlistResponseSchema; - -export async function POST(request: NextRequest) { - try { - const ip = request.headers.get("x-forwarded-for") ?? "anonymous"; - const { success } = await baseRateLimit.limit(ip); - if (!success) { - return NextResponse.json({ error: "Too many requests" }, { status: 429 }); - } - - const body = await request.json().catch(() => null); - if (!body) { - return NextResponse.json( - { error: "Invalid JSON in request body" }, - { status: 400 } - ); - } - - const parsed = requestSchema.safeParse(body); - if (!parsed.success) { - return NextResponse.json( - { - error: "Invalid request parameters", - details: parsed.error.flatten().fieldErrors, - }, - { status: 400 } - ); - } - - const { email } = parsed.data; - - const existing = await db - .select({ id: exportWaitlist.id }) - .from(exportWaitlist) - .where(eq(exportWaitlist.email, email)) - .limit(1); - - if (existing.length > 0) { - const responseData = { success: true, alreadySubscribed: true } as const; - const validated = responseSchema.safeParse(responseData); - if (!validated.success) { - return NextResponse.json( - { error: "Internal response formatting error" }, - { status: 500 } - ); - } - return NextResponse.json(validated.data); - } - - await db.insert(exportWaitlist).values({ - id: randomUUID(), - email, - createdAt: new Date(), - updatedAt: new Date(), - }); - - const responseData = { success: true } as const; - const validated = responseSchema.safeParse(responseData); - if (!validated.success) { - return NextResponse.json( - { error: "Internal response formatting error" }, - { status: 500 } - ); - } - return NextResponse.json(validated.data); - } catch (error) { - console.error("Waitlist API error:", error); - return NextResponse.json( - { error: "Internal server error" }, - { status: 500 } - ); - } -} diff --git a/apps/web/src/app/base-page.tsx b/apps/web/src/app/base-page.tsx new file mode 100644 index 000000000..a3d56fe54 --- /dev/null +++ b/apps/web/src/app/base-page.tsx @@ -0,0 +1,53 @@ +import { Header } from "@/components/header"; +import { Footer } from "@/components/footer"; +import { cn } from "@/utils/ui"; + +interface BasePageProps { + children: React.ReactNode; + className?: string; + mainClassName?: string; + maxWidth?: "3xl" | "6xl" | "full"; + title?: string; + description?: string; +} + +export function BasePage({ + children, + className = "", + mainClassName = "", + maxWidth = "3xl", + title, + description, +}: BasePageProps) { + const maxWidthClass = { + "3xl": "max-w-3xl", + "6xl": "max-w-6xl", + full: "max-w-full", + }[maxWidth]; + + return ( +
+
+
+ {title && description && ( +
+

+ {title} +

+

+ {description} +

+
+ )} + {children} +
+
+
+ ); +} diff --git a/apps/web/src/app/blog/[slug]/page.tsx b/apps/web/src/app/blog/[slug]/page.tsx index d759050b7..46c6163d9 100644 --- a/apps/web/src/app/blog/[slug]/page.tsx +++ b/apps/web/src/app/blog/[slug]/page.tsx @@ -1,144 +1,152 @@ -import { Header } from "@/components/header"; -import Prose from "@/components/ui/prose"; -import { Separator } from "@/components/ui/separator"; -import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog-query"; -import { Metadata } from "next"; +import type { Metadata } from "next"; import Image from "next/image"; import { notFound } from "next/navigation"; +import { BasePage } from "@/app/base-page"; +import Prose from "@/components/ui/prose"; +import { Separator } from "@/components/ui/separator"; +import { getPosts, getSinglePost, processHtmlContent } from "@/lib/blog/query"; +import type { Author, Post } from "@/types/blog"; type PageProps = { - params: Promise<{ slug: string }>; - searchParams: Promise<{ [key: string]: string | string[] | undefined }>; + params: Promise<{ slug: string }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }; export async function generateMetadata({ - params, + params, }: PageProps): Promise { - const slug = (await params).slug; - - const data = await getSinglePost(slug); - - if (!data || !data.post) return {}; - - return { - title: data.post.title, - description: data.post.description, - twitter: { - title: `${data.post.title}`, - description: `${data.post.description}`, - card: "summary_large_image", - images: [ - { - url: data.post.coverImage, - width: "1200", - height: "630", - alt: data.post.title, - }, - ], - }, - openGraph: { - type: "article", - images: [ - { - url: data.post.coverImage, - width: "1200", - height: "630", - alt: data.post.title, - }, - ], - title: data.post.title, - description: data.post.description, - publishedTime: new Date(data.post.publishedAt).toISOString(), - authors: [ - ...data.post.authors.map((author: { name: string }) => author.name), - ], - }, - }; + const slug = (await params).slug; + + const data = await getSinglePost({ slug }); + + if (!data || !data.post) return {}; + + return { + title: data.post.title, + description: data.post.description, + twitter: { + title: `${data.post.title}`, + description: `${data.post.description}`, + card: "summary_large_image", + images: [ + { + url: data.post.coverImage, + width: "1200", + height: "630", + alt: data.post.title, + }, + ], + }, + openGraph: { + type: "article", + images: [ + { + url: data.post.coverImage, + width: "1200", + height: "630", + alt: data.post.title, + }, + ], + title: data.post.title, + description: data.post.description, + publishedTime: new Date(data.post.publishedAt).toISOString(), + authors: data.post.authors.map((author: Author) => author.name), + }, + }; } export async function generateStaticParams() { - const data = await getPosts(); - if (!data || !data.posts.length) return []; + const data = await getPosts(); + if (!data || !data.posts.length) return []; + + return data.posts.map((post) => ({ + slug: post.slug, + })); +} + +export default async function BlogPostPage({ params }: PageProps) { + const slug = (await params).slug; + const data = await getSinglePost({ slug }); + if (!data || !data.post) return notFound(); + + const html = await processHtmlContent({ html: data.post.content }); - return data.posts.map((post) => ({ - slug: post.slug, - })); + return ( + + + + + + ); } -async function Page({ params }: PageProps) { - const slug = (await params).slug; - const data = await getSinglePost(slug); - if (!data || !data.post) return notFound(); - - const html = await processHtmlContent(data.post.content); - - const formattedDate = new Date(data.post.publishedAt).toLocaleDateString( - "en-US", - { - day: "numeric", - month: "long", - year: "numeric", - } - ); - - return ( -
-
- -
-
-
-
-
- -
-
- {data.post.coverImage && ( -
- {data.post.title} -
- )} -
- -
- -

- {data.post.title} -

-
- {data.post.authors[0] && ( - <> - {data.post.authors[0].name} -

- {data.post.authors[0].name} -

- - )} -
-
- -
- -
-
-
-
- ); +function PostHeader({ post }: { post: Post }) { + const formattedDate = new Date(post.publishedAt).toLocaleDateString("en-US", { + day: "numeric", + month: "long", + year: "numeric", + }); + + return ( + <> + {post.coverImage && } + + + + + ); +} + +function PostCoverImage({ post }: { post: Post }) { + return ( +
+ {post.title} +
+ ); } -export default Page; +function PostMeta({ date, publishedAt }: { date: string; publishedAt: Date }) { + return ( +
+ +
+ ); +} + +function PostTitle({ title }: { title: string }) { + return ( +

{title}

+ ); +} + +function PostAuthor({ author }: { author?: Author }) { + if (!author) return null; + + return ( +
+ {author.name} +

{author.name}

+
+ ); +} + +function PostContent({ html }: { html: string }) { + return ( +
+ +
+ ); +} diff --git a/apps/web/src/app/blog/page.tsx b/apps/web/src/app/blog/page.tsx index 08da9fd78..9e3b6d112 100644 --- a/apps/web/src/app/blog/page.tsx +++ b/apps/web/src/app/blog/page.tsx @@ -1,98 +1,73 @@ -import { Metadata } from "next"; -import { Header } from "@/components/header"; -import { Card, CardContent } from "@/components/ui/card"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import type { Metadata } from "next"; import Link from "next/link"; -import { getPosts } from "@/lib/blog-query"; -import Image from "next/image"; +import { BasePage } from "@/app/base-page"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Separator } from "@/components/ui/separator"; +import { getPosts } from "@/lib/blog/query"; +import type { Author, Post } from "@/types/blog"; export const metadata: Metadata = { - title: "Blog - OpenCut", - description: - "Read the latest news and updates about OpenCut, the free and open-source video editor.", - openGraph: { - title: "Blog - OpenCut", - description: - "Read the latest news and updates about OpenCut, the free and open-source video editor.", - type: "website", - }, + title: "Blog - OpenCut", + description: + "Read the latest news and updates about OpenCut, the free and open-source video editor.", + openGraph: { + title: "Blog - OpenCut", + description: + "Read the latest news and updates about OpenCut, the free and open-source video editor.", + type: "website", + }, }; export default async function BlogPage() { - const data = await getPosts(); - if (!data || !data.posts) return
No posts yet
; - - return ( -
-
+ const data = await getPosts(); + if (!data || !data.posts) return
No posts yet
; -
-
-
-
-
+ return ( + +
+ {data.posts.map((post) => ( +
+ + +
+ ))} +
+
+ ); +} -
-
-

- Blog -

-

- Read the latest news and updates about OpenCut, the free and - open-source video editor. -

-
-
- {data.posts.map((post) => ( - - - {post.coverImage && ( -
- {post.title} -
- )} +function BlogPostItem({ post }: { post: Post }) { + return ( + +
+
+

{post.title}

+

{post.description}

+
+ {post.authors && post.authors.length > 0 && ( + + )} +
+ + ); +} - - {post.authors && post.authors.length > 0 && ( -
- {post.authors.map((author, index) => ( -
- - - - {author.name.charAt(0).toUpperCase()} - - - - {author.name} - - {index < post.authors.length - 1 && ( - - )} -
- ))} -
- )} -

{post.title}

-

{post.description}

-
-
- - ))} -
-
-
-
- ); +function AuthorList({ authors }: { authors: Author[] }) { + return ( +
+ {authors.map((author) => ( +
+ + + + {author.name.charAt(0).toUpperCase()} + + +
+ ))} +
+ ); } diff --git a/apps/web/src/app/contributors/page.tsx b/apps/web/src/app/contributors/page.tsx index 3ad990234..217038523 100644 --- a/apps/web/src/app/contributors/page.tsx +++ b/apps/web/src/app/contributors/page.tsx @@ -1,330 +1,242 @@ -import { Metadata } from "next"; -import { Header } from "@/components/header"; -import { Card, CardContent } from "@/components/ui/card"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Button } from "@/components/ui/button"; -import { ExternalLink } from "lucide-react"; +import type { Metadata } from "next"; import Link from "next/link"; -import { - GithubIcon, - MarbleIcon, - VercelIcon, - DataBuddyIcon, -} from "@/components/icons"; -import { Badge } from "@/components/ui/badge"; -import { EXTERNAL_TOOLS } from "@/constants/site"; +import { GitHubContributeSection } from "@/components/gitHub-contribute-section"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Card, CardContent } from "@/components/ui/card"; +import { EXTERNAL_TOOLS } from "@/constants/site-constants"; +import { BasePage } from "../base-page"; export const metadata: Metadata = { - title: "Contributors - OpenCut", - description: - "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.", - openGraph: { - title: "Contributors - OpenCut", - description: - "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.", - type: "website", - }, + title: "Contributors - OpenCut", + description: + "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.", + openGraph: { + title: "Contributors - OpenCut", + description: + "Meet the amazing people who contribute to OpenCut, the free and open-source video editor.", + type: "website", + }, }; interface Contributor { - id: number; - login: string; - avatar_url: string; - html_url: string; - contributions: number; - type: string; + id: number; + login: string; + avatar_url: string; + html_url: string; + contributions: number; + type: string; } async function getContributors(): Promise { - try { - const response = await fetch( - "https://api.github.com/repos/OpenCut-app/OpenCut/contributors?per_page=100", - { - headers: { - Accept: "application/vnd.github.v3+json", - "User-Agent": "OpenCut-Web-App", - }, - next: { revalidate: 600 }, // 10 minutes - } as RequestInit - ); - - if (!response.ok) { - console.error("Failed to fetch contributors"); - return []; - } - - const contributors = (await response.json()) as Contributor[]; - - const filteredContributors = contributors.filter( - (contributor: Contributor) => contributor.type === "User" - ); - - return filteredContributors; - } catch (error) { - console.error("Error fetching contributors:", error); - return []; - } + try { + const response = await fetch( + "https://api.github.com/repos/OpenCut-app/OpenCut/contributors?per_page=100", + { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "OpenCut-Web-App", + }, + next: { revalidate: 600 }, // 10 minutes + }, + ); + + if (!response.ok) { + console.error("Failed to fetch contributors"); + return []; + } + + const contributors = (await response.json()) as Contributor[]; + + const filteredContributors = contributors.filter( + (contributor) => contributor.type === "User", + ); + + return filteredContributors; + } catch (error) { + console.error("Error fetching contributors:", error); + return []; + } } export default async function ContributorsPage() { - const contributors = await getContributors(); - const topContributors = contributors.slice(0, 2); - const otherContributors = contributors.slice(2); - - return ( -
-
- -
-
-
-
-
- -
-
-
- - - - Open Source - - -

- Contributors -

-

- Meet the amazing developers who are building the future of video - editing -

- -
-
-
- {contributors.length} - contributors -
-
-
- - {contributors.reduce((sum, c) => sum + c.contributions, 0)} - - contributions -
-
-
- - {topContributors.length > 0 && ( -
-
-

- Top Contributors -

-

- Leading the way in contributions -

-
- -
- {topContributors.map((contributor, index) => ( - -
-
- - -
- - - - {contributor.login.charAt(0).toUpperCase()} - - -
-

- {contributor.login} -

-
- - {contributor.contributions} - - contributions -
-
-
-
- - ))} -
-
- )} - - {otherContributors.length > 0 && ( -
-
-

- All Contributors -

-

- Everyone who makes OpenCut better -

-
- -
- {otherContributors.map((contributor, index) => ( - -
- - - - {contributor.login.charAt(0).toUpperCase()} - - -

- {contributor.login} -

-

- {contributor.contributions} -

-
- - ))} -
-
- )} - - {contributors.length === 0 && ( -
-
- -
-

- No contributors found -

-

- Unable to load contributors at the moment. Check back later or - view on GitHub. -

- - - -
- )} + const contributors = await getContributors(); + const topContributors = contributors.slice(0, 2); + const otherContributors = contributors.slice(2); + const totalContributions = contributors.reduce( + (sum, c) => sum + c.contributions, + 0, + ); + + return ( + +
+ + +
+ +
+ {topContributors.length > 0 && ( + + )} + {otherContributors.length > 0 && ( + + )} + + +
+
+ ); +} -
-
-

External Tools

-

- Tools we use to build OpenCut -

-
+function StatItem({ value, label }: { value: number; label: string }) { + return ( +
+
+ {value} + {label} +
+ ); +} -
- {EXTERNAL_TOOLS.map((tool, index) => { - const IconComponent = { - MarbleIcon, - VercelIcon, - DataBuddyIcon, - }[tool.icon]; +function TopContributorsSection({ + contributors, +}: { + contributors: Contributor[]; +}) { + return ( +
+
+

Top contributors

+

+ Leading the way in contributions +

+
+ +
+ {contributors.map((contributor) => ( + + ))} +
+
+ ); +} - return ( - - - -
-
- -
-
-

- {tool.name} -

-

- {tool.description} -

-
-
- - ); - })} -
-
+function TopContributorCard({ contributor }: { contributor: Contributor }) { + return ( + + + + + + + {contributor.login.charAt(0).toUpperCase()} + + +
+

{contributor.login}

+
+ {contributor.contributions} + contributions +
+
+
+
+ + ); +} -
-
-

Join the community

-

- OpenCut is built by developers like you. Every contribution, - no matter how small, helps make video editing more accessible - for everyone. -

+function AllContributorsSection({ + contributors, +}: { + contributors: Contributor[]; +}) { + return ( +
+
+

All contributors

+

+ Everyone who makes OpenCut better +

+
+ +
+ {contributors.map((contributor) => ( + +
+ + + + {contributor.login.charAt(0).toUpperCase()} + + +
+

{contributor.login}

+

+ {contributor.contributions} +

+
+
+ + ))} +
+
+ ); +} -
- - - - - - -
-
-
-
-
-
-
- ); +function ExternalToolsSection() { + return ( +
+
+

External tools

+

Tools we use to build OpenCut

+
+ +
+ {EXTERNAL_TOOLS.map((tool, index) => ( + + + + +
+

{tool.name}

+

+ {tool.description} +

+
+
+
+ + ))} +
+
+ ); } diff --git a/apps/web/src/app/editor/[project_id]/layout.tsx b/apps/web/src/app/editor/[project_id]/layout.tsx deleted file mode 100644 index c37e78b7e..000000000 --- a/apps/web/src/app/editor/[project_id]/layout.tsx +++ /dev/null @@ -1,9 +0,0 @@ -"use client"; - -export default function EditorLayout({ - children, -}: { - children: React.ReactNode; -}) { - return
{children}
; -} diff --git a/apps/web/src/app/editor/[project_id]/page.tsx b/apps/web/src/app/editor/[project_id]/page.tsx index 1b53b6b5b..e652e928c 100644 --- a/apps/web/src/app/editor/[project_id]/page.tsx +++ b/apps/web/src/app/editor/[project_id]/page.tsx @@ -1,459 +1,108 @@ "use client"; -import { useEffect, useRef } from "react"; -import { useParams, useRouter } from "next/navigation"; +import { useParams } from "next/navigation"; import { - ResizablePanelGroup, - ResizablePanel, - ResizableHandle, + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, } from "@/components/ui/resizable"; -import { MediaPanel } from "@/components/editor/media-panel"; -import { PropertiesPanel } from "@/components/editor/properties-panel"; +import { AssetsPanel } from "@/components/editor/panels/assets"; +import { PropertiesPanel } from "@/components/editor/panels/properties"; import { Timeline } from "@/components/editor/timeline"; -import { PreviewPanel } from "@/components/editor/preview-panel"; +import { PreviewPanel } from "@/components/editor/panels/preview"; import { EditorHeader } from "@/components/editor/editor-header"; -import { usePanelStore } from "@/stores/panel-store"; -import { useProjectStore } from "@/stores/project-store"; import { EditorProvider } from "@/components/providers/editor-provider"; -import { usePlaybackControls } from "@/hooks/use-playback-controls"; import { Onboarding } from "@/components/editor/onboarding"; +import { MigrationDialog } from "@/components/editor/dialogs/migration-dialog"; +import { usePanelStore } from "@/stores/panel-store"; export default function Editor() { - const { - toolsPanel, - previewPanel, - mainContent, - timeline, - setToolsPanel, - setPreviewPanel, - setMainContent, - setTimeline, - propertiesPanel, - setPropertiesPanel, - activePreset, - resetCounter, - } = usePanelStore(); - - const { - activeProject, - loadProject, - createNewProject, - isInvalidProjectId, - markProjectIdAsInvalid, - } = useProjectStore(); - const params = useParams(); - const router = useRouter(); - const projectId = params.project_id as string; - const handledProjectIds = useRef>(new Set()); - const isInitializingRef = useRef(false); - - usePlaybackControls(); - - useEffect(() => { - let isCancelled = false; - - const initProject = async () => { - if (!projectId) { - return; - } - - // Prevent duplicate initialization - if (isInitializingRef.current) { - return; - } - - // Check if project is already loaded - if (activeProject?.id === projectId) { - return; - } - - // Check global invalid tracking first (most important for preventing duplicates) - if (isInvalidProjectId(projectId)) { - return; - } - - // Check if we've already handled this project ID locally - if (handledProjectIds.current.has(projectId)) { - return; - } - - // Mark as initializing to prevent race conditions - isInitializingRef.current = true; - handledProjectIds.current.add(projectId); - - try { - await loadProject(projectId); - - // Check if component was unmounted during async operation - if (isCancelled) { - return; - } - - // Project loaded successfully - isInitializingRef.current = false; - } catch (error) { - // Check if component was unmounted during async operation - if (isCancelled) { - return; - } - - // More specific error handling - only create new project for actual "not found" errors - const isProjectNotFound = - error instanceof Error && - (error.message.includes("not found") || - error.message.includes("does not exist") || - error.message.includes("Project not found")); - - if (isProjectNotFound) { - // Mark this project ID as invalid globally BEFORE creating project - markProjectIdAsInvalid(projectId); - - try { - const newProjectId = await createNewProject("Untitled Project"); - - // Check again if component was unmounted - if (isCancelled) { - return; - } - - router.replace(`/editor/${newProjectId}`); - } catch (createError) { - console.error("Failed to create new project:", createError); - } - } else { - // For other errors (storage issues, corruption, etc.), don't create new project - console.error( - "Project loading failed with recoverable error:", - error - ); - // Remove from handled set so user can retry - handledProjectIds.current.delete(projectId); - } - - isInitializingRef.current = false; - } - }; - - initProject(); - - // Cleanup function to cancel async operations - return () => { - isCancelled = true; - isInitializingRef.current = false; - }; - }, [ - projectId, - loadProject, - createNewProject, - router, - isInvalidProjectId, - markProjectIdAsInvalid, - ]); - - return ( - -
- -
- {activePreset === "media" ? ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) : activePreset === "inspector" ? ( - - setPropertiesPanel(100 - size)} - className="min-w-0 min-h-0" - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) : activePreset === "vertical-preview" ? ( - - setPreviewPanel(100 - size)} - className="min-w-0 min-h-0" - > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ) : ( - - - {/* Main content area */} - - {/* Tools Panel */} - - - - - - - {/* Preview Area */} - - - - - - - - - - - - - + const params = useParams(); + const projectId = params.project_id as string; + + return ( + +
+ +
+ +
+ + +
+
+ ); +} - {/* Timeline */} - - - -
- )} -
- -
-
- ); +function EditorLayout() { + const { panels, setPanel } = usePanelStore(); + + return ( + { + setPanel("mainContent", sizes[0] ?? panels.mainContent); + setPanel("timeline", sizes[1] ?? panels.timeline); + }} + > + + { + setPanel("tools", sizes[0] ?? panels.tools); + setPanel("preview", sizes[1] ?? panels.preview); + setPanel("properties", sizes[2] ?? panels.properties); + }} + > + + + + + + + + + + + + + + + + + + + + + + + + + ); } diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index ca78ee263..d0da1f512 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -8,85 +8,88 @@ @plugin "tailwindcss-animate"; :root { - /* Custom colors - light mode (default) */ - --background: hsl(0, 0%, 100%); - --foreground: hsl(0 0% 11%); - --card: hsl(216, 8%, 86%); - --card-foreground: hsl(0 0% 2%); - --popover: hsl(0, 0%, 100%); - --popover-foreground: hsl(0 0% 2%); - --primary: hsl(205, 84%, 47%); - --primary-foreground: hsl(0 0% 91%); - --secondary: hsl(216, 13%, 92%); - --secondary-foreground: hsl(0 0% 2%); - --muted: hsl(0 0% 85.1%); - --muted-foreground: hsl(0 0% 50%); - --accent: hsl(216, 13%, 92%); - --accent-foreground: hsl(0 0% 2%); - --destructive: hsl(0, 83%, 50%); - --destructive-foreground: hsl(0, 0%, 100%); - --border: hsl(0 0% 83%); - --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%); - --panel-background: hsl(216 13% 92%); - --panel-accent: hsl(216, 8%, 86%); - - /* Radius base */ - --radius: 1rem; + --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-foreground: hsl(0 0% 2%); + --primary: hsl(203, 100%, 50%); + --primary-hover: hsl(203, 100%, 45%); + --primary-foreground: hsl(0, 0%, 100%); + --secondary: hsl(216 13% 94%); + --secondary-foreground: hsl(0 0% 2%); + --muted: hsl(0 0% 85.1%); + --muted-foreground: hsl(0 0% 50%); + --accent: hsl(216, 13%, 88%); + --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% 88%); + --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%); + --panel-background: hsl(216 13% 94%); + --panel-accent: hsl(216, 8%, 88%); + --sidebar: hsl(0 0% 98%); } .dark { - /* Custom colors - dark mode */ - --background: hsl(0 0% 4%); - --foreground: hsl(0 0% 89%); - --card: hsl(0 0% 14.9%); - --card-foreground: hsl(0 0% 98%); - --popover: hsl(0 0% 14.9%); - --popover-foreground: hsl(0 0% 98%); - --primary: hsl(205, 84%, 53%); - --primary-foreground: hsl(0 0% 9%); - --secondary: hsl(0 0% 14.9%); - --secondary-foreground: hsl(0 0% 98%); - --muted: hsl(0 0% 14.9%); - --muted-foreground: hsl(0 0% 63.9%); - --accent: hsl(0 0% 14.9%); - --accent-foreground: hsl(0 0% 98%); - --destructive: hsl(0 100% 60%); - --destructive-foreground: hsl(0 0% 98%); - --border: hsl(0 0% 17%); - --input: hsl(0 0% 14.9%); - --ring: hsl(0 0% 83.1%); - --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% 3.9%); - --sidebar-foreground: hsl(0 0% 98%); - --sidebar-primary: hsl(0 0% 98%); - --sidebar-primary-foreground: hsl(0 0% 9%); - --sidebar-accent: hsl(0 0% 14.9%); - --sidebar-accent-foreground: hsl(0 0% 98%); - --sidebar-border: hsl(0 0% 14.9%); - --sidebar-ring: hsl(0 0% 83.1%); - --panel-background: hsl(0 0% 11%); - --panel-accent: hsl(0 0% 15%); + --background: hsl(0 0% 4%); + --foreground: hsl(0 0% 89%); + --card: hsl(0 0% 4%); + --card-foreground: hsl(0 0% 89%); + --popover: hsl(0 0% 14.9%); + --popover-foreground: hsl(0 0% 98%); + --primary: hsl(203, 100%, 50%); + --primary-hover: hsl(203, 100%, 45%); + --primary-foreground: hsl(0 0% 9%); + --secondary: hsl(0 0% 14.9%); + --secondary-foreground: hsl(0 0% 98%); + --muted: hsl(0 0% 14.9%); + --muted-foreground: hsl(0 0% 63.9%); + --accent: hsl(0, 0%, 28%); + --accent-foreground: hsl(0 0% 98%); + --destructive: hsl(0 83%, 55%); + --destructive-foreground: hsl(0 0% 98%); + --constructive: hsl(141, 71%, 48%); + --constructive-foreground: hsl(0 0% 100%); + --border: hsl(0 0% 17%); + --input: hsl(0 0% 14.9%); + --ring: hsl(0 0% 83.1%); + --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% 3.9%); + --sidebar-foreground: hsl(0 0% 98%); + --sidebar-primary: hsl(0 0% 98%); + --sidebar-primary-foreground: hsl(0 0% 9%); + --sidebar-accent: hsl(0 0% 14.9%); + --sidebar-accent-foreground: hsl(0 0% 98%); + --sidebar-border: hsl(0 0% 14.9%); + --sidebar-ring: hsl(0 0% 83.1%); + --panel-background: hsl(0 0% 11%); + --panel-accent: hsl(0 0% 15%); + --sidebar: hsl(240 5.9% 10%); } @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. @@ -94,152 +97,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; - } + *, + ::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; + } } @theme inline { - /* Responsive breakpoints */ - --breakpoint-xs: 30rem; - - /* Typography */ - --font-sans: var(--font-inter), sans-serif; - - /* Font sizes */ - --text-base: 0.95rem; - --text-base--line-height: calc(1.5 / 0.95); - --text-xs: 0.8rem; - --text-xs--line-height: calc(1 / 0.8); - - /* Border radius */ - --radius-lg: var(--radius); - --radius-md: calc(var(--radius) - 2px); - --radius-sm: calc(var(--radius) - 8px); - - /* 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-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - - --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); - - /* 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); - - /* Panel */ - --color-panel: var(--panel-background); - --color-panel-accent: var(--panel-accent); - - /* 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-up { - from { - height: var(--radix-accordion-content-height); - } - to { - height: 0; - } - } + /* Responsive breakpoints */ + --breakpoint-xs: 30rem; + + /* Typography */ + --font-sans: var(--font-inter), sans-serif; + + /* Font sizes */ + --text-xl: 1.20rem; + --text-base: 0.92rem; + --text-base--line-height: calc(1.5 / 0.95); + --text-xs: 0.75rem; + --text-xs--line-height: calc(1 / 0.8); + + /* 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); + + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-primary-hover: var(--primary-hover); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + + --color-constructive: var(--constructive); + --color-constructive-foreground: var(--constructive-foreground); + + --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); + + /* 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); + + /* Panel */ + --color-panel: var(--panel-background); + --color-panel-accent: var(--panel-accent); + + /* 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-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; + } } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 7e32ea22d..2b3d0c303 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,62 +1,58 @@ import { ThemeProvider } from "next-themes"; -import { Analytics } from "@vercel/analytics/react"; import Script from "next/script"; import "./globals.css"; import { Toaster } from "../components/ui/sonner"; import { TooltipProvider } from "../components/ui/tooltip"; -import { StorageProvider } from "../components/storage-provider"; -import { ScenesMigrator } from "../components/providers/migrators/scenes-migrator"; import { baseMetaData } from "./metadata"; -import { defaultFont } from "../lib/font-config"; import { BotIdClient } from "botid/client"; -import { env } from "@/env"; +import { webEnv } from "@opencut/env/web"; +import { Inter } from "next/font/google"; + +const siteFont = Inter({ subsets: ["latin"] }); export const metadata = baseMetaData; const protectedRoutes = [ - { - path: "/none", - method: "GET", - }, - { - path: "/api/waitlist/export", - method: "POST", - }, + { + path: "/none", + method: "GET", + }, ]; export default function RootLayout({ - children, + children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode; }>) { - return ( - - - - - - - - - {children} - - - -