From af93ac8f47d48d4c0b3c23e43aab3c203673f749 Mon Sep 17 00:00:00 2001 From: AskIt Date: Mon, 26 Jan 2026 02:26:16 +0100 Subject: [PATCH] feat(api,media,auth): Add video upload support and improve media handling - Add video upload support with separate size limits (100MB for videos, 10MB for images) - Support MP4, WebM, and MOV video formats alongside existing image formats - Add video styling to post cards and compose component with proper object-fit - Improve storage URL configuration with fallback error handling for missing endpoints - Auto-enable NSFW settings when importing accounts from NSFW nodes - Add age verification requirement for importing accounts on NSFW nodes - Allow empty string for avatar and header URLs in profile update validation - Update login page to display node name in browser title - Enhance media upload validation with type-specific error messages --- src/app/api/account/import/route.ts | 16 ++++++++- src/app/api/auth/me/route.ts | 4 +-- src/app/api/media/upload/route.ts | 24 +++++++++----- src/app/api/uploads/route.ts | 6 ++-- src/app/globals.css | 16 +++++++++ src/app/login/page.tsx | 36 +++++++++++++++++++- src/components/Compose.tsx | 44 ++++++++++++++++--------- src/components/PostCard.tsx | 30 ++++++++++++++--- src/lib/contexts/AccentColorContext.tsx | 4 +++ src/lib/types.ts | 1 + 10 files changed, 145 insertions(+), 36 deletions(-) diff --git a/src/app/api/account/import/route.ts b/src/app/api/account/import/route.ts index 4fb8b31..3ba9d6b 100644 --- a/src/app/api/account/import/route.ts +++ b/src/app/api/account/import/route.ts @@ -6,7 +6,7 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { db, users, posts, media, follows } from '@/db'; +import { db, users, posts, media, follows, nodes } from '@/db'; import { eq } from 'drizzle-orm'; import * as crypto from 'crypto'; import { v4 as uuid } from 'uuid'; @@ -191,6 +191,20 @@ export async function POST(req: NextRequest) { postsCount: importPosts.length, }).returning(); + // Check if this is an NSFW node and auto-enable NSFW settings + const node = await db.query.nodes.findFirst({ + where: eq(nodes.domain, nodeDomain), + }); + + if (node?.isNsfw) { + await db.update(users) + .set({ + nsfwEnabled: true, + isNsfw: true + }) + .where(eq(users.id, newUser.id)); + } + // Import posts let importedPosts = 0; for (const post of importPosts) { diff --git a/src/app/api/auth/me/route.ts b/src/app/api/auth/me/route.ts index a31e637..fb284fb 100644 --- a/src/app/api/auth/me/route.ts +++ b/src/app/api/auth/me/route.ts @@ -7,8 +7,8 @@ import { z } from 'zod'; const updateProfileSchema = z.object({ displayName: z.string().min(1).max(50).optional(), bio: z.string().max(160).optional().nullable(), - avatarUrl: z.string().url().optional().nullable(), - headerUrl: z.string().url().optional().nullable(), + avatarUrl: z.string().url().or(z.string().length(0)).optional().nullable(), + headerUrl: z.string().url().or(z.string().length(0)).optional().nullable(), website: z.string().url().or(z.string().length(0)).optional().nullable(), }); diff --git a/src/app/api/media/upload/route.ts b/src/app/api/media/upload/route.ts index 1727ecd..7962d88 100644 --- a/src/app/api/media/upload/route.ts +++ b/src/app/api/media/upload/route.ts @@ -4,8 +4,10 @@ import { requireAuth } from '@/lib/auth'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import { v4 as uuidv4 } from 'uuid'; -const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB -const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; +const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB for images +const MAX_VIDEO_SIZE = 100 * 1024 * 1024; // 100MB for videos +const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; +const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/webm', 'video/quicktime']; export async function POST(req: NextRequest) { try { @@ -20,16 +22,20 @@ export async function POST(req: NextRequest) { } // Validate file type - if (!ALLOWED_TYPES.includes(file.type)) { + const isImage = ALLOWED_IMAGE_TYPES.includes(file.type); + const isVideo = ALLOWED_VIDEO_TYPES.includes(file.type); + + if (!isImage && !isVideo) { return NextResponse.json({ - error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP' + error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, MP4, WebM, MOV' }, { status: 400 }); } - // Validate file size - if (file.size > MAX_FILE_SIZE) { + // Validate file size based on type + const maxSize = isVideo ? MAX_VIDEO_SIZE : MAX_IMAGE_SIZE; + if (file.size > maxSize) { return NextResponse.json({ - error: 'File too large. Maximum size: 10MB' + error: `File too large. Maximum size: ${isVideo ? '100MB' : '10MB'}` }, { status: 400 }); } @@ -62,8 +68,10 @@ export async function POST(req: NextRequest) { let url = ''; if (process.env.STORAGE_PUBLIC_BASE_URL) { url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`; - } else { + } else if (process.env.STORAGE_ENDPOINT) { url = `${process.env.STORAGE_ENDPOINT}/${bucket}/${filename}`; + } else { + return NextResponse.json({ error: 'Storage not configured - missing STORAGE_PUBLIC_BASE_URL or STORAGE_ENDPOINT' }, { status: 500 }); } // Store media record diff --git a/src/app/api/uploads/route.ts b/src/app/api/uploads/route.ts index fcfaf17..4f67aff 100644 --- a/src/app/api/uploads/route.ts +++ b/src/app/api/uploads/route.ts @@ -39,10 +39,10 @@ export async function POST(req: NextRequest) { let url = ''; if (process.env.STORAGE_PUBLIC_BASE_URL) { url = `${process.env.STORAGE_PUBLIC_BASE_URL}/${filename}`; - } else { - // Fallback if no public URL configured (e.g. valid for some providers) - // This might need adjustment based on provider + } else if (process.env.STORAGE_ENDPOINT) { url = `${process.env.STORAGE_ENDPOINT}/${bucket}/${filename}`; + } else { + return NextResponse.json({ error: 'Storage not configured - missing STORAGE_PUBLIC_BASE_URL or STORAGE_ENDPOINT' }, { status: 500 }); } return NextResponse.json({ url }); diff --git a/src/app/globals.css b/src/app/globals.css index 58f17b4..9bd9606 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -347,6 +347,15 @@ a.btn-primary:visited { display: block; } +.post-media-item video { + width: 100%; + height: 100%; + max-height: 360px; + object-fit: cover; + display: block; + cursor: pointer; +} + .video-embed-container { position: relative; padding-bottom: 56.25%; @@ -532,6 +541,13 @@ a.btn-primary:visited { display: block; } +.compose-media-item video { + width: 100%; + height: 120px; + object-fit: cover; + display: block; +} + .compose-media-remove { position: absolute; top: 6px; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 088b86a..2abfd22 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -24,6 +24,7 @@ export default function LoginPage() { const [importPassword, setImportPassword] = useState(''); const [importHandle, setImportHandle] = useState(''); const [acceptedCompliance, setAcceptedCompliance] = useState(false); + const [importAgeVerified, setImportAgeVerified] = useState(false); const [importSuccess, setImportSuccess] = useState(null); // Fetch node info @@ -37,6 +38,10 @@ export default function LoginPage() { logoUrl: data.logoUrl || undefined, isNsfw: data.isNsfw || false }); + // Update page title + if (data.name && data.name !== 'Synapsis') { + document.title = data.name; + } setNodeInfoLoaded(true); }) .catch(() => { @@ -77,6 +82,11 @@ export default function LoginPage() { return; } + if (nodeInfo.isNsfw && !importAgeVerified) { + setError('You must verify your age to import an account on this node'); + return; + } + setLoading(true); setError(''); setImportSuccess(null); @@ -564,11 +574,35 @@ export default function LoginPage() { + {nodeInfo.isNsfw && ( +
+ +
+ )} + diff --git a/src/components/Compose.tsx b/src/components/Compose.tsx index aa2281b..565239d 100644 --- a/src/components/Compose.tsx +++ b/src/components/Compose.tsx @@ -3,10 +3,14 @@ import { useState, useEffect } from 'react'; import AutoTextarea from '@/components/AutoTextarea'; import { Post, Attachment } from '@/lib/types'; -import { ImageIcon, AlertTriangle } from 'lucide-react'; +import { ImageIcon, AlertTriangle, Film } from 'lucide-react'; import { VideoEmbed } from '@/components/VideoEmbed'; import { formatFullHandle } from '@/lib/utils/handle'; +interface MediaAttachment extends Attachment { + mimeType?: string; +} + interface ComposeProps { onPost: (content: string, mediaIds: string[], linkPreview?: any, replyToId?: string, isNsfw?: boolean) => void; replyingTo?: Post | null; @@ -18,7 +22,7 @@ interface ComposeProps { export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What's happening?", isReply }: ComposeProps) { const [content, setContent] = useState(''); const [isPosting, setIsPosting] = useState(false); - const [attachments, setAttachments] = useState([]); + const [attachments, setAttachments] = useState([]); const [isUploading, setIsUploading] = useState(false); const [uploadError, setUploadError] = useState(null); const [linkPreview, setLinkPreview] = useState(null); @@ -97,7 +101,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What setUploadError(null); setIsUploading(true); - const uploaded: Attachment[] = []; + const uploaded: MediaAttachment[] = []; for (const file of selectedFiles) { try { @@ -117,6 +121,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What id: data.media.id, url: data.media.url || data.url, altText: data.media.altText ?? null, + mimeType: data.media.mimeType ?? file.type, }); } catch (error) { console.error('Upload failed', error); @@ -153,18 +158,25 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What /> {attachments.length > 0 && (
- {attachments.map((item) => ( -
- {item.altText - -
- ))} + {attachments.map((item) => { + const isVideo = item.mimeType?.startsWith('video/'); + return ( +
+ {isVideo ? ( +
+ ); + })}
)} @@ -219,7 +231,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What {isUploading ? '...' : } = 4} diff --git a/src/components/PostCard.tsx b/src/components/PostCard.tsx index 3726b5c..9218099 100644 --- a/src/components/PostCard.tsx +++ b/src/components/PostCard.tsx @@ -511,11 +511,31 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide, {post.media && post.media.length > 0 && (
- {post.media.map((item) => ( -
- {item.altText -
- ))} + {post.media.map((item) => { + const isVideo = item.mimeType?.startsWith('video/'); + return ( +
+ {isVideo ? ( +
+ ); + })}
)} diff --git a/src/lib/contexts/AccentColorContext.tsx b/src/lib/contexts/AccentColorContext.tsx index f674f02..7444016 100644 --- a/src/lib/contexts/AccentColorContext.tsx +++ b/src/lib/contexts/AccentColorContext.tsx @@ -43,6 +43,10 @@ export function AccentColorProvider({ children }: { children: ReactNode }) { setAccentColor(data.accentColor); applyAccentColor(data.accentColor); } + // Update page title if node has a custom name + if (data?.name && data.name !== 'Synapsis') { + document.title = data.name; + } }) .catch(() => {}); }, []); diff --git a/src/lib/types.ts b/src/lib/types.ts index 52edb01..9996564 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -26,6 +26,7 @@ export interface MediaItem { id: string; url: string; altText?: string | null; + mimeType?: string | null; } export interface Attachment {