mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
+68
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user