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
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
+35
-1
@@ -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<string | null>(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() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{nodeInfo.isNsfw && (
|
||||
<div style={{
|
||||
marginBottom: '20px',
|
||||
padding: '12px',
|
||||
background: 'rgba(239, 68, 68, 0.05)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.2)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
}}>
|
||||
<label style={{ display: 'flex', gap: '8px', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={importAgeVerified}
|
||||
onChange={(e) => setImportAgeVerified(e.target.checked)}
|
||||
style={{ marginTop: '3px' }}
|
||||
/>
|
||||
<span style={{ fontSize: '12px', color: 'var(--foreground-secondary)', lineHeight: 1.4 }}>
|
||||
<strong style={{ color: 'var(--error)', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
||||
<TriangleAlert size={12} /> Age Verification:
|
||||
</strong> This node contains adult or sensitive content. I confirm that I am at least 18 years of age.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary btn-lg"
|
||||
style={{ width: '100%' }}
|
||||
disabled={loading || !importFile || !importPassword || !importHandle || !acceptedCompliance}
|
||||
disabled={loading || !importFile || !importPassword || !importHandle || !acceptedCompliance || (nodeInfo.isNsfw && !importAgeVerified)}
|
||||
>
|
||||
{loading ? 'Importing...' : 'Import Account'}
|
||||
</button>
|
||||
|
||||
+28
-16
@@ -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<Attachment[]>([]);
|
||||
const [attachments, setAttachments] = useState<MediaAttachment[]>([]);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [linkPreview, setLinkPreview] = useState<any>(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 && (
|
||||
<div className="compose-media-grid">
|
||||
{attachments.map((item) => (
|
||||
<div className="compose-media-item" key={item.id}>
|
||||
<img src={item.url} alt={item.altText || 'Upload preview'} />
|
||||
<button
|
||||
type="button"
|
||||
className="compose-media-remove"
|
||||
onClick={() => handleRemoveAttachment(item.id)}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{attachments.map((item) => {
|
||||
const isVideo = item.mimeType?.startsWith('video/');
|
||||
return (
|
||||
<div className="compose-media-item" key={item.id}>
|
||||
{isVideo ? (
|
||||
<video src={item.url} muted playsInline preload="metadata" />
|
||||
) : (
|
||||
<img src={item.url} alt={item.altText || 'Upload preview'} />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="compose-media-remove"
|
||||
onClick={() => handleRemoveAttachment(item.id)}
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -219,7 +231,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What
|
||||
{isUploading ? '...' : <ImageIcon size={20} />}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept="image/*,video/mp4,video/webm,video/quicktime"
|
||||
multiple
|
||||
onChange={handleMediaSelect}
|
||||
disabled={isUploading || attachments.length >= 4}
|
||||
|
||||
@@ -511,11 +511,31 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
|
||||
|
||||
{post.media && post.media.length > 0 && (
|
||||
<div className="post-media-grid">
|
||||
{post.media.map((item) => (
|
||||
<div className="post-media-item" key={item.id}>
|
||||
<img src={item.url} alt={item.altText || 'Post media'} loading="lazy" />
|
||||
</div>
|
||||
))}
|
||||
{post.media.map((item) => {
|
||||
const isVideo = item.mimeType?.startsWith('video/');
|
||||
return (
|
||||
<div className="post-media-item" key={item.id}>
|
||||
{isVideo ? (
|
||||
<video
|
||||
src={item.url}
|
||||
autoPlay
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="metadata"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const video = e.currentTarget;
|
||||
video.muted = !video.muted;
|
||||
}}
|
||||
title="Click to toggle sound"
|
||||
/>
|
||||
) : (
|
||||
<img src={item.url} alt={item.altText || 'Post media'} loading="lazy" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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(() => {});
|
||||
}, []);
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface MediaItem {
|
||||
id: string;
|
||||
url: string;
|
||||
altText?: string | null;
|
||||
mimeType?: string | null;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
|
||||
Reference in New Issue
Block a user