chore(init): scaffold OpenFrame — Next.js + Bun + shadcn

Initialize OpenFrame project with core scaffold and UI foundation.

- Project scaffold: Next.js 16.1 (App Router) + Bun runtime
- UI: shadcn/ui + TailwindCSS; landing, dashboard, project, and auth UIs
- Video support: provider abstraction (YouTube first), video player page with timestamped comments and custom timeline
- Auth & DB: NextAuth skeleton and Prisma schema (Postgres) included
- UX: dark-mode toggle, full-width layouts, comments sidebar, video route moved to /watch/[videoId]
- Dev: added YouTube iframe integration, custom controls, and TypeScript types
- Next steps: DB migrations, API routes (CRUD), real data wiring, voice-recording & sharing
This commit is contained in:
Yusuf İpek
2026-02-05 22:11:20 +03:00
commit 264392c2ec
64 changed files with 7917 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
import type { VideoProvider, VideoMetadata, EmbedOptions, ThumbnailSize } from './types';
// Direct video URL patterns (for future self-hosted videos)
const DIRECT_VIDEO_PATTERNS = [
/\.(mp4|webm|ogg|mov)(\?.*)?$/i,
];
export const directProvider: VideoProvider = {
id: 'direct',
name: 'Direct Upload',
icon: 'Upload',
canHandle(url: string): boolean {
// Check for common video extensions or our own domain
return DIRECT_VIDEO_PATTERNS.some(pattern => pattern.test(url));
},
extractVideoId(url: string): string | null {
// For direct uploads, the "videoId" is the full URL
// In production, this would be a storage key/path
if (this.canHandle(url)) {
return url;
}
return null;
},
getEmbedUrl(videoId: string, options: EmbedOptions = {}): string {
// For direct videos, we'll use HTML5 video player
// The videoId IS the URL for direct uploads
const params = new URLSearchParams();
if (options.startTime) params.set('t', String(Math.floor(options.startTime)));
const queryString = params.toString();
return `${videoId}${queryString ? `#t=${options.startTime}` : ''}`;
},
getThumbnailUrl(videoId: string, size: ThumbnailSize = 'medium'): string {
// For direct uploads, thumbnail would be generated server-side
// Return a placeholder for now
return '/placeholder-video-thumbnail.png';
},
async getMetadata(videoId: string): Promise<VideoMetadata> {
// For direct uploads, metadata would be stored in our database
// This is a placeholder implementation
const filename = videoId.split('/').pop() || 'Video';
const nameWithoutExt = filename.replace(/\.[^/.]+$/, '');
return {
title: nameWithoutExt,
thumbnailUrl: this.getThumbnailUrl(videoId),
};
},
};
+129
View File
@@ -0,0 +1,129 @@
// Video Provider Registry - Central place to manage all video providers
import { youtubeProvider } from './youtube';
import { vimeoProvider } from './vimeo';
import { directProvider } from './direct';
import type { VideoProvider, VideoSource, VideoMetadata, VideoProviderType } from './types';
// Export types
export * from './types';
// Registry of all available providers
const providers: VideoProvider[] = [
youtubeProvider,
vimeoProvider,
directProvider,
];
// Provider lookup map for quick access
const providerMap = new Map<string, VideoProvider>(
providers.map(p => [p.id, p])
);
/**
* Detect which provider can handle a given URL
*/
export function detectProvider(url: string): VideoProvider | null {
for (const provider of providers) {
if (provider.canHandle(url)) {
return provider;
}
}
return null;
}
/**
* Get a provider by its ID
*/
export function getProvider(providerId: VideoProviderType): VideoProvider | null {
return providerMap.get(providerId) ?? null;
}
/**
* Get all available providers
*/
export function getAllProviders(): VideoProvider[] {
return [...providers];
}
/**
* Parse a video URL and return a VideoSource object
*/
export function parseVideoUrl(url: string): VideoSource | null {
const provider = detectProvider(url);
if (!provider) {
return null;
}
const videoId = provider.extractVideoId(url);
if (!videoId) {
return null;
}
return {
providerId: provider.id as VideoProviderType,
videoId,
originalUrl: url,
};
}
/**
* Fetch metadata for a video source
*/
export async function fetchVideoMetadata(source: VideoSource): Promise<VideoMetadata | null> {
const provider = getProvider(source.providerId);
if (!provider) {
return null;
}
try {
return await provider.getMetadata(source.videoId);
} catch (error) {
console.error('Failed to fetch video metadata:', error);
return null;
}
}
/**
* Get embed URL for a video source
*/
export function getEmbedUrl(source: VideoSource, options?: Parameters<VideoProvider['getEmbedUrl']>[1]): string | null {
const provider = getProvider(source.providerId);
if (!provider) {
return null;
}
return provider.getEmbedUrl(source.videoId, options);
}
/**
* Get thumbnail URL for a video source
*/
export function getThumbnailUrl(source: VideoSource, size?: Parameters<VideoProvider['getThumbnailUrl']>[1]): string | null {
const provider = getProvider(source.providerId);
if (!provider) {
return null;
}
return provider.getThumbnailUrl(source.videoId, size);
}
/**
* Check if a URL is a valid video URL from any supported provider
*/
export function isValidVideoUrl(url: string): boolean {
return detectProvider(url) !== null;
}
/**
* Get the provider icon name for UI display
*/
export function getProviderIcon(providerId: VideoProviderType): string {
const provider = getProvider(providerId);
return provider?.icon ?? 'Video';
}
+49
View File
@@ -0,0 +1,49 @@
// Video Provider Types - Future-proof abstraction for multiple video sources
export interface VideoMetadata {
title: string;
description?: string;
thumbnailUrl: string;
duration?: number; // in seconds
author?: string;
authorUrl?: string;
uploadDate?: Date;
}
export interface VideoProvider {
id: string;
name: string;
icon: string; // Lucide icon name
// URL handling
canHandle(url: string): boolean;
extractVideoId(url: string): string | null;
// Embed and display
getEmbedUrl(videoId: string, options?: EmbedOptions): string;
getThumbnailUrl(videoId: string, size?: ThumbnailSize): string;
// Metadata fetching
getMetadata(videoId: string): Promise<VideoMetadata>;
}
export interface EmbedOptions {
autoplay?: boolean;
startTime?: number; // in seconds
controls?: boolean;
loop?: boolean;
muted?: boolean;
}
export type ThumbnailSize = 'small' | 'medium' | 'large' | 'maxres';
// Supported provider types - extend as we add more
export type VideoProviderType = 'youtube' | 'vimeo' | 'direct';
// Video source stored in database
export interface VideoSource {
providerId: VideoProviderType;
videoId: string;
originalUrl: string;
metadata?: VideoMetadata;
}
+90
View File
@@ -0,0 +1,90 @@
import type { VideoProvider, VideoMetadata, EmbedOptions, ThumbnailSize } from './types';
// Vimeo URL patterns
const VIMEO_PATTERNS = [
/vimeo\.com\/(\d+)/,
/vimeo\.com\/video\/(\d+)/,
/player\.vimeo\.com\/video\/(\d+)/,
/vimeo\.com\/channels\/\w+\/(\d+)/,
/vimeo\.com\/groups\/\w+\/videos\/(\d+)/,
];
export const vimeoProvider: VideoProvider = {
id: 'vimeo',
name: 'Vimeo',
icon: 'Video', // Lucide doesn't have Vimeo icon
canHandle(url: string): boolean {
return VIMEO_PATTERNS.some(pattern => pattern.test(url));
},
extractVideoId(url: string): string | null {
for (const pattern of VIMEO_PATTERNS) {
const match = url.match(pattern);
if (match?.[1]) {
return match[1];
}
}
return null;
},
getEmbedUrl(videoId: string, options: EmbedOptions = {}): string {
const params = new URLSearchParams();
if (options.autoplay) params.set('autoplay', '1');
if (options.startTime) params.set('t', `${Math.floor(options.startTime)}s`);
if (options.loop) params.set('loop', '1');
if (options.muted) params.set('muted', '1');
// Better UX options
params.set('byline', '0');
params.set('portrait', '0');
params.set('title', '0');
const queryString = params.toString();
return `https://player.vimeo.com/video/${videoId}${queryString ? `?${queryString}` : ''}`;
},
getThumbnailUrl(videoId: string, size: ThumbnailSize = 'medium'): string {
// Vimeo requires API call to get thumbnail, return placeholder
// In production, cache these after fetching metadata
const sizeMap: Record<ThumbnailSize, number> = {
small: 200,
medium: 400,
large: 640,
maxres: 1280,
};
// This is a placeholder - actual thumbnail comes from metadata API
return `https://vumbnail.com/${videoId}_${sizeMap[size]}.jpg`;
},
async getMetadata(videoId: string): Promise<VideoMetadata> {
try {
const response = await fetch(
`https://vimeo.com/api/oembed.json?url=https://vimeo.com/${videoId}`
);
if (!response.ok) {
throw new Error('Failed to fetch video metadata');
}
const data = await response.json();
return {
title: data.title,
description: data.description,
thumbnailUrl: data.thumbnail_url,
duration: data.duration,
author: data.author_name,
authorUrl: data.author_url,
uploadDate: data.upload_date ? new Date(data.upload_date) : undefined,
};
} catch (error) {
return {
title: 'Vimeo Video',
thumbnailUrl: this.getThumbnailUrl(videoId, 'large'),
};
}
},
};
+87
View File
@@ -0,0 +1,87 @@
import type { VideoProvider, VideoMetadata, EmbedOptions, ThumbnailSize } from './types';
// YouTube URL patterns
const YOUTUBE_PATTERNS = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
/youtube\.com\/watch\?.*v=([a-zA-Z0-9_-]{11})/,
];
export const youtubeProvider: VideoProvider = {
id: 'youtube',
name: 'YouTube',
icon: 'Youtube',
canHandle(url: string): boolean {
return YOUTUBE_PATTERNS.some(pattern => pattern.test(url));
},
extractVideoId(url: string): string | null {
for (const pattern of YOUTUBE_PATTERNS) {
const match = url.match(pattern);
if (match?.[1]) {
return match[1];
}
}
return null;
},
getEmbedUrl(videoId: string, options: EmbedOptions = {}): string {
const params = new URLSearchParams();
// Enable JS API for programmatic control
params.set('enablejsapi', '1');
params.set('origin', typeof window !== 'undefined' ? window.location.origin : '');
if (options.autoplay) params.set('autoplay', '1');
if (options.startTime) params.set('start', String(Math.floor(options.startTime)));
if (options.controls === false) params.set('controls', '0');
if (options.loop) params.set('loop', '1');
if (options.muted) params.set('mute', '1');
// Better UX options
params.set('rel', '0'); // Don't show related videos from other channels
params.set('modestbranding', '1'); // Minimal YouTube branding
return `https://www.youtube.com/embed/${videoId}?${params.toString()}`;
},
getThumbnailUrl(videoId: string, size: ThumbnailSize = 'medium'): string {
const sizeMap: Record<ThumbnailSize, string> = {
small: 'default', // 120x90
medium: 'mqdefault', // 320x180
large: 'hqdefault', // 480x360
maxres: 'maxresdefault', // 1280x720
};
return `https://img.youtube.com/vi/${videoId}/${sizeMap[size]}.jpg`;
},
async getMetadata(videoId: string): Promise<VideoMetadata> {
// Using oEmbed API - no API key required
// For production, you might want to use YouTube Data API for more data
try {
const response = await fetch(
`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`
);
if (!response.ok) {
throw new Error('Failed to fetch video metadata');
}
const data = await response.json();
return {
title: data.title,
thumbnailUrl: data.thumbnail_url,
author: data.author_name,
authorUrl: data.author_url,
};
} catch (error) {
// Fallback with minimal data
return {
title: 'YouTube Video',
thumbnailUrl: this.getThumbnailUrl(videoId, 'large'),
};
}
},
};