feat(admin,branding): Add favicon support and video blur component

- Add favicon_url column to nodes table via database migration
- Create favicon upload endpoint with media handling
- Implement favicon upload UI in admin panel with preview
- Add favicon removal functionality in admin settings
- Create BlurredVideo component for improved video rendering
- Update PostCard to use new BlurredVideo component
- Update node schema to include favicon_url field
- Add favicon upload state management and error handling
- Enables customization of browser tab icon for node branding
This commit is contained in:
AskIt
2026-01-26 02:47:33 +01:00
parent 7a95086161
commit b7b6076846
9 changed files with 219 additions and 8 deletions
+2
View File
@@ -0,0 +1,2 @@
-- Add favicon_url column to nodes table
ALTER TABLE "nodes" ADD COLUMN IF NOT EXISTS "favicon_url" text;
+82
View File
@@ -69,6 +69,7 @@ export default function AdminPage() {
rules: '', rules: '',
bannerUrl: '', bannerUrl: '',
logoUrl: '', logoUrl: '',
faviconUrl: '',
accentColor: '#FFFFFF', accentColor: '#FFFFFF',
isNsfw: false, isNsfw: false,
}); });
@@ -77,6 +78,8 @@ export default function AdminPage() {
const [bannerUploadError, setBannerUploadError] = useState<string | null>(null); const [bannerUploadError, setBannerUploadError] = useState<string | null>(null);
const [isUploadingLogo, setIsUploadingLogo] = useState(false); const [isUploadingLogo, setIsUploadingLogo] = useState(false);
const [logoUploadError, setLogoUploadError] = useState<string | null>(null); const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
const [isUploadingFavicon, setIsUploadingFavicon] = useState(false);
const [faviconUploadError, setFaviconUploadError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
fetch('/api/admin/me') fetch('/api/admin/me')
@@ -136,6 +139,7 @@ export default function AdminPage() {
rules: data.rules || '', rules: data.rules || '',
bannerUrl: data.bannerUrl || '', bannerUrl: data.bannerUrl || '',
logoUrl: data.logoUrl || '', logoUrl: data.logoUrl || '',
faviconUrl: data.faviconUrl || '',
accentColor: data.accentColor || '#FFFFFF', accentColor: data.accentColor || '#FFFFFF',
isNsfw: data.isNsfw || false, isNsfw: data.isNsfw || false,
}); });
@@ -270,6 +274,41 @@ export default function AdminPage() {
} }
}; };
const handleFaviconUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
setFaviconUploadError(null);
setIsUploadingFavicon(true);
try {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/media/upload', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (!res.ok || !data.url) {
throw new Error(data.error || 'Upload failed');
}
const nextSettings = {
...nodeSettings,
faviconUrl: data.media?.url || data.url,
};
setNodeSettings(nextSettings);
await handleSaveSettings(nextSettings);
} catch (error) {
console.error('Favicon upload failed', error);
setFaviconUploadError('Upload failed. Please try again.');
} finally {
setIsUploadingFavicon(false);
}
};
const handleUserAction = async (id: string, action: 'suspend' | 'unsuspend' | 'silence' | 'unsilence') => { const handleUserAction = async (id: string, action: 'suspend' | 'unsuspend' | 'silence' | 'unsilence') => {
const needsReason = action === 'suspend' || action === 'silence'; const needsReason = action === 'suspend' || action === 'silence';
const reason = needsReason ? window.prompt('Reason (optional):') || '' : ''; const reason = needsReason ? window.prompt('Reason (optional):') || '' : '';
@@ -602,6 +641,49 @@ export default function AdminPage() {
)} )}
</div> </div>
<div>
<label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Favicon</label>
<p style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginBottom: '8px' }}>
The icon shown in browser tabs. Recommended: 32x32 or 64x64 PNG.
</p>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
<label className="btn btn-ghost btn-sm">
{isUploadingFavicon ? 'Uploading...' : 'Upload favicon'}
<input
type="file"
accept="image/png,image/x-icon,image/svg+xml"
onChange={handleFaviconUpload}
disabled={isUploadingFavicon}
style={{ display: 'none' }}
/>
</label>
{nodeSettings.faviconUrl && (
<button
className="btn btn-ghost btn-sm"
onClick={async () => {
const nextSettings = { ...nodeSettings, faviconUrl: '' };
setNodeSettings(nextSettings);
await handleSaveSettings(nextSettings);
}}
>
Remove favicon
</button>
)}
{faviconUploadError && (
<span style={{ fontSize: '12px', color: 'var(--danger)' }}>{faviconUploadError}</span>
)}
</div>
{nodeSettings.faviconUrl && (
<div style={{ marginTop: '8px', padding: '12px', borderRadius: '8px', border: '1px solid var(--border)', background: 'var(--background-secondary)', display: 'inline-block' }}>
<img
src={nodeSettings.faviconUrl}
alt="Custom favicon"
style={{ width: '32px', height: '32px', objectFit: 'contain' }}
/>
</div>
)}
</div>
<div> <div>
<label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Short Description</label> <label style={{ fontSize: '13px', fontWeight: 500, marginBottom: '4px', display: 'block' }}>Short Description</label>
<AutoTextarea <AutoTextarea
+2
View File
@@ -24,6 +24,7 @@ export async function PATCH(req: NextRequest) {
rules: data.rules, rules: data.rules,
bannerUrl: data.bannerUrl, bannerUrl: data.bannerUrl,
logoUrl: data.logoUrl, logoUrl: data.logoUrl,
faviconUrl: data.faviconUrl,
accentColor: data.accentColor, accentColor: data.accentColor,
isNsfw: data.isNsfw ?? false, isNsfw: data.isNsfw ?? false,
}).returning(); }).returning();
@@ -36,6 +37,7 @@ export async function PATCH(req: NextRequest) {
rules: data.rules, rules: data.rules,
bannerUrl: data.bannerUrl, bannerUrl: data.bannerUrl,
logoUrl: data.logoUrl, logoUrl: data.logoUrl,
faviconUrl: data.faviconUrl,
accentColor: data.accentColor, accentColor: data.accentColor,
isNsfw: data.isNsfw ?? node.isNsfw, isNsfw: data.isNsfw ?? node.isNsfw,
updatedAt: new Date(), updatedAt: new Date(),
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { db, nodes } from '@/db';
import { eq } from 'drizzle-orm';
export async function GET() {
try {
if (!db) {
// Redirect to default favicon
return NextResponse.redirect(new URL('/favicon.png', process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'));
}
const domain = process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:3000';
const node = await db.query.nodes.findFirst({
where: eq(nodes.domain, domain),
columns: { faviconUrl: true },
});
if (node?.faviconUrl) {
// Redirect to custom favicon
return NextResponse.redirect(node.faviconUrl);
}
// Redirect to default favicon
const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || `https://${domain}`;
return NextResponse.redirect(new URL('/favicon.png', baseUrl));
} catch (error) {
console.error('Favicon error:', error);
return NextResponse.redirect(new URL('/favicon.png', process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'));
}
}
+33
View File
@@ -356,6 +356,39 @@ a.btn-primary:visited {
background: #000; background: #000;
} }
/* Blurred background video effect */
.blurred-video-container {
position: relative;
width: 100%;
max-height: 360px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #000;
}
.blurred-video-bg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 110%;
height: 110%;
object-fit: cover;
filter: blur(20px) brightness(0.6);
pointer-events: none;
}
.blurred-video-main {
position: relative;
width: 100%;
max-height: 360px;
object-fit: contain;
cursor: pointer;
z-index: 1;
}
.video-embed-container { .video-embed-container {
position: relative; position: relative;
padding-bottom: 56.25%; padding-bottom: 56.25%;
+1 -1
View File
@@ -18,7 +18,7 @@ export const metadata: Metadata = {
description: "Synapsis is designed to function like a global signal layer rather than a culture-bound platform. Anyone can run their own node and still participate in a shared, interconnected network, with global identity, clean terminology, and a modern interface that feels current rather than experimental.", description: "Synapsis is designed to function like a global signal layer rather than a culture-bound platform. Anyone can run their own node and still participate in a shared, interconnected network, with global identity, clean terminology, and a modern interface that feels current rather than experimental.",
manifest: "/manifest.json", manifest: "/manifest.json",
icons: { icons: {
icon: "/favicon.png", icon: "/api/favicon",
}, },
themeColor: "#0a0a0a", themeColor: "#0a0a0a",
viewport: "width=device-width, initial-scale=1, maximum-scale=1", viewport: "width=device-width, initial-scale=1, maximum-scale=1",
+66
View File
@@ -0,0 +1,66 @@
'use client';
import { useRef, useEffect } from 'react';
interface BlurredVideoProps {
src: string;
onClick?: (e: React.MouseEvent<HTMLVideoElement>) => void;
}
export default function BlurredVideo({ src, onClick }: BlurredVideoProps) {
const containerRef = useRef<HTMLDivElement>(null);
const mainVideoRef = useRef<HTMLVideoElement>(null);
const bgVideoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
// Sync playback between main and background videos
const mainVideo = mainVideoRef.current;
const bgVideo = bgVideoRef.current;
if (mainVideo && bgVideo) {
const syncTime = () => {
if (Math.abs(mainVideo.currentTime - bgVideo.currentTime) > 0.1) {
bgVideo.currentTime = mainVideo.currentTime;
}
};
mainVideo.addEventListener('seeked', syncTime);
mainVideo.addEventListener('play', () => bgVideo.play());
mainVideo.addEventListener('pause', () => bgVideo.pause());
return () => {
mainVideo.removeEventListener('seeked', syncTime);
};
}
}, []);
return (
<div ref={containerRef} className="blurred-video-container">
{/* Background blurred video */}
<video
ref={bgVideoRef}
src={src}
autoPlay
muted
loop
playsInline
preload="metadata"
className="blurred-video-bg"
aria-hidden="true"
/>
{/* Main video */}
<video
ref={mainVideoRef}
src={src}
autoPlay
muted
loop
playsInline
preload="metadata"
className="blurred-video-main"
onClick={onClick}
title="Click to toggle sound"
/>
</div>
);
}
+2 -7
View File
@@ -8,6 +8,7 @@ import { Post } from '@/lib/types';
import { useAuth } from '@/lib/contexts/AuthContext'; import { useAuth } from '@/lib/contexts/AuthContext';
import { useToast } from '@/lib/contexts/ToastContext'; import { useToast } from '@/lib/contexts/ToastContext';
import { VideoEmbed } from '@/components/VideoEmbed'; import { VideoEmbed } from '@/components/VideoEmbed';
import BlurredVideo from '@/components/BlurredVideo';
import { formatFullHandle } from '@/lib/utils/handle'; import { formatFullHandle } from '@/lib/utils/handle';
// Component for link preview image that hides on error // Component for link preview image that hides on error
@@ -516,19 +517,13 @@ export function PostCard({ post, onLike, onRepost, onComment, onDelete, onHide,
return ( return (
<div className="post-media-item" key={item.id}> <div className="post-media-item" key={item.id}>
{isVideo ? ( {isVideo ? (
<video <BlurredVideo
src={item.url} src={item.url}
autoPlay
muted
loop
playsInline
preload="metadata"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
const video = e.currentTarget; const video = e.currentTarget;
video.muted = !video.muted; video.muted = !video.muted;
}} }}
title="Click to toggle sound"
/> />
) : ( ) : (
<img src={item.url} alt={item.altText || 'Post media'} loading="lazy" /> <img src={item.url} alt={item.altText || 'Post media'} loading="lazy" />
+1
View File
@@ -14,6 +14,7 @@ export const nodes = pgTable('nodes', {
rules: text('rules'), rules: text('rules'),
bannerUrl: text('banner_url'), bannerUrl: text('banner_url'),
logoUrl: text('logo_url'), logoUrl: text('logo_url'),
faviconUrl: text('favicon_url'),
accentColor: text('accent_color').default('#FFFFFF'), accentColor: text('accent_color').default('#FFFFFF'),
publicKey: text('public_key'), publicKey: text('public_key'),
// NSFW settings // NSFW settings