diff --git a/src/app/api/media/stuffbox/uploads/route.ts b/src/app/api/media/stuffbox/uploads/route.ts index 85ae247..ee4ad5d 100644 --- a/src/app/api/media/stuffbox/uploads/route.ts +++ b/src/app/api/media/stuffbox/uploads/route.ts @@ -3,18 +3,16 @@ import { z } from 'zod'; import { requireAuth } from '@/lib/auth'; import { createUpload, StuffboxApiError } from '@/lib/stuffbox/client'; import { getStuffboxAccess } from '@/lib/stuffbox/tokens'; +import { ALLOWED_MEDIA_TYPES, MAX_IMAGE_SIZE, MAX_AUDIO_SIZE, MAX_VIDEO_SIZE } from '@/lib/media/upload-policy'; const uploadSchema = z.object({ filename: z.string().min(1).max(255), - mimeType: z.enum([ - 'image/jpeg', 'image/png', 'image/gif', 'image/webp', - 'video/mp4', 'video/webm', 'video/quicktime', - ]), - size: z.number().int().positive().max(100 * 1024 * 1024), + mimeType: z.enum(ALLOWED_MEDIA_TYPES), + size: z.number().int().positive().max(Math.max(MAX_VIDEO_SIZE, MAX_AUDIO_SIZE)), sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(), }).superRefine((upload, context) => { - if (upload.mimeType.startsWith('image/') && upload.size > 10 * 1024 * 1024) { - context.addIssue({ code: 'too_big', maximum: 10 * 1024 * 1024, origin: 'number', inclusive: true, path: ['size'], message: 'Images must be 10MB or smaller' }); + if (upload.mimeType.startsWith('image/') && upload.size > MAX_IMAGE_SIZE) { + context.addIssue({ code: 'too_big', maximum: MAX_IMAGE_SIZE, origin: 'number', inclusive: true, path: ['size'], message: 'Images must be 10MB or smaller' }); } }); diff --git a/src/app/api/media/upload/route.ts b/src/app/api/media/upload/route.ts index 63e6376..c6af549 100644 --- a/src/app/api/media/upload/route.ts +++ b/src/app/api/media/upload/route.ts @@ -4,11 +4,7 @@ import { requireAuth } from '@/lib/auth'; import { getStorageSession, createStorageSession } from '@/lib/storage/session'; import { uploadWithStorageCredentials } from '@/lib/storage/s3'; import { v4 as uuidv4 } from 'uuid'; - -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']; +import { getMaxMediaSize, getMediaKind } from '@/lib/media/upload-policy'; export async function POST(req: NextRequest) { try { @@ -24,20 +20,19 @@ export async function POST(req: NextRequest) { } // Validate file type - const isImage = ALLOWED_IMAGE_TYPES.includes(file.type); - const isVideo = ALLOWED_VIDEO_TYPES.includes(file.type); - - if (!isImage && !isVideo) { + const mediaKind = getMediaKind(file.type); + + if (mediaKind === 'unsupported') { return NextResponse.json({ - error: 'Invalid file type. Allowed: JPEG, PNG, GIF, WebP, MP4, WebM, MOV' + error: 'Invalid file type. Upload an image, video, MP3, M4A, AAC, WAV, OGG, or FLAC file.' }, { status: 400 }); } // Validate file size based on type - const maxSize = isVideo ? MAX_VIDEO_SIZE : MAX_IMAGE_SIZE; - if (file.size > maxSize) { + const maxSize = getMaxMediaSize(file.type); + if (maxSize !== null && file.size > maxSize) { return NextResponse.json({ - error: `File too large. Maximum size: ${isVideo ? '100MB' : '10MB'}` + error: `File too large. Maximum size: ${mediaKind === 'image' ? '10MB' : '100MB'}` }, { status: 400 }); } diff --git a/src/app/globals.css b/src/app/globals.css index 9c16a79..68dcbac 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -443,6 +443,79 @@ a.btn-primary:visited { background: var(--background-tertiary); } +.post-media-item.audio { + grid-column: 1 / -1; + overflow: visible; +} + +.audio-player { + display: flex; + align-items: center; + gap: 14px; + min-height: 92px; + padding: 16px; + border-radius: var(--radius-md); + background: + radial-gradient(circle at 10% 50%, color-mix(in srgb, var(--accent) 20%, transparent), transparent 42%), + var(--background-secondary); +} + +.audio-player-toggle { + display: grid; + place-items: center; + flex: 0 0 48px; + width: 48px; + height: 48px; + padding: 0; + border: 0; + border-radius: 50%; + color: var(--background); + background: var(--accent); + cursor: pointer; + transition: transform 0.15s ease, filter 0.15s ease; +} + +.audio-player-toggle:hover { + filter: brightness(1.08); + transform: scale(1.04); +} + +.audio-player-body { + min-width: 0; + flex: 1; +} + +.audio-player-title { + display: flex; + align-items: center; + gap: 7px; + margin-bottom: 9px; + overflow: hidden; + color: var(--foreground); + font-size: 14px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.audio-player-progress { + display: block; + width: 100%; + height: 4px; + margin: 0; + accent-color: var(--accent); + cursor: pointer; +} + +.audio-player-time { + display: flex; + justify-content: space-between; + margin-top: 7px; + color: var(--foreground-tertiary); + font-size: 11px; + font-variant-numeric: tabular-nums; +} + .post-media-item img { width: 100%; height: auto; @@ -716,6 +789,30 @@ a.btn-primary:visited { display: block; } +.compose-media-item.audio { + width: min(100%, 320px); + max-height: none; +} + +.compose-audio-preview { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + height: 80px; + padding: 0 38px 0 14px; + color: var(--foreground-secondary); + background: color-mix(in srgb, var(--accent) 8%, var(--background-tertiary)); +} + +.compose-audio-preview span { + overflow: hidden; + font-size: 13px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + .compose-media-remove { position: absolute; top: 4px; diff --git a/src/components/AudioPlayer.tsx b/src/components/AudioPlayer.tsx new file mode 100644 index 0000000..f50e6d1 --- /dev/null +++ b/src/components/AudioPlayer.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { Music2, Pause, Play } from 'lucide-react'; + +interface AudioPlayerProps { + src: string; + title?: string; +} + +function formatTime(value: number): string { + if (!Number.isFinite(value) || value < 0) return '0:00'; + const minutes = Math.floor(value / 60); + const seconds = Math.floor(value % 60); + return `${minutes}:${seconds.toString().padStart(2, '0')}`; +} + +export function AudioPlayer({ src, title = 'Audio track' }: AudioPlayerProps) { + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + + const togglePlayback = async () => { + const audio = audioRef.current; + if (!audio) return; + if (audio.paused) { + await audio.play(); + } else { + audio.pause(); + } + }; + + const seek = (value: number) => { + const audio = audioRef.current; + if (!audio) return; + audio.currentTime = value; + setCurrentTime(value); + }; + + return ( +
event.stopPropagation()}> +
+ ); +} diff --git a/src/components/Compose.tsx b/src/components/Compose.tsx index 1023f31..7e66ebc 100644 --- a/src/components/Compose.tsx +++ b/src/components/Compose.tsx @@ -3,15 +3,17 @@ import { useState, useEffect, useRef } from 'react'; import AutoTextarea from '@/components/AutoTextarea'; import { Post, Attachment } from '@/lib/types'; -import { ImageIcon, AlertTriangle, Film } from 'lucide-react'; +import { AlertTriangle, Music2, Paperclip } from 'lucide-react'; import { VideoEmbed } from '@/components/VideoEmbed'; import { useFormattedHandle } from '@/lib/utils/handle'; import { useAuth } from '@/lib/contexts/AuthContext'; import { StorageConfigurationPrompt } from '@/components/StorageConfigurationPrompt'; import { getStorageProvider, MediaUploadError, uploadMediaFile } from '@/lib/stuffbox/browser-upload'; +import { getMediaKind } from '@/lib/media/upload-policy'; interface MediaAttachment extends Attachment { mimeType?: string; + filename?: string; } interface ComposeProps { @@ -142,6 +144,7 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What url: media.url, altText: media.altText ?? null, mimeType: media.mimeType ?? file.type, + filename: file.name, }); } catch (error) { if (error instanceof MediaUploadError && error.code === 'STORAGE_NOT_CONFIGURED') { @@ -229,11 +232,16 @@ export function Compose({ onPost, replyingTo, onCancelReply, placeholder = "What {attachments.length > 0 && (
{attachments.map((item) => { - const isVideo = item.mimeType?.startsWith('video/'); + const mediaKind = getMediaKind(item.mimeType); return ( -
- {isVideo ? ( +
+ {mediaKind === 'video' ? (