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
+68
View File
@@ -0,0 +1,68 @@
import NextAuth from 'next-auth';
import { PrismaAdapter } from '@auth/prisma-adapter';
import Google from 'next-auth/providers/google';
import GitHub from 'next-auth/providers/github';
import Credentials from 'next-auth/providers/credentials';
import { db } from '@/lib/db';
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(db),
providers: [
// Google OAuth - uncomment and add credentials when ready
// Google({
// clientId: process.env.GOOGLE_CLIENT_ID,
// clientSecret: process.env.GOOGLE_CLIENT_SECRET,
// }),
// GitHub OAuth - uncomment and add credentials when ready
// GitHub({
// clientId: process.env.GITHUB_ID,
// clientSecret: process.env.GITHUB_SECRET,
// }),
// Email/Password - for development, add proper provider in production
Credentials({
name: 'credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials) {
// TODO: Implement proper credential validation
// This is a placeholder for development
if (!credentials?.email) {
return null;
}
// In production, verify password hash here
const user = await db.user.findUnique({
where: { email: credentials.email as string },
});
return user;
},
}),
],
session: {
strategy: 'jwt',
},
pages: {
signIn: '/login',
// signUp: '/register',
// error: '/auth/error',
},
callbacks: {
async session({ session, token }) {
if (token.sub && session.user) {
session.user.id = token.sub;
}
return session;
},
async jwt({ token, user }) {
if (user) {
token.sub = user.id;
}
return token;
},
},
});
+38
View File
@@ -0,0 +1,38 @@
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
function createPrismaClient() {
// In development without a database, we'll create a mock-friendly client
// For production or when DATABASE_URL is set, use the real adapter
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
console.warn('DATABASE_URL not set - database features will not work');
// Return a client that will throw clear errors when used
return new PrismaClient({
// This will fail on actual DB operations but allows imports to work
adapter: new PrismaPg(new Pool({ connectionString: 'postgresql://localhost:5432/dummy' })),
});
}
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool);
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
}
export const db = globalForPrisma.prisma ?? createPrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = db;
}
export default db;
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+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'),
};
}
},
};