Initial commit
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState, useCallback, ReactNode } from 'react';
|
||||
|
||||
interface AccentColorContextType {
|
||||
accentColor: string;
|
||||
refreshAccentColor: () => void;
|
||||
}
|
||||
|
||||
const AccentColorContext = createContext<AccentColorContextType | null>(null);
|
||||
|
||||
function applyAccentColor(color: string) {
|
||||
const cleaned = color.trim();
|
||||
const normalized = cleaned.startsWith('#') ? cleaned : `#${cleaned}`;
|
||||
const hexMatch = /^#([0-9a-fA-F]{6})$/.exec(normalized);
|
||||
if (!hexMatch) return;
|
||||
|
||||
const hex = hexMatch[1];
|
||||
const r = parseInt(hex.slice(0, 2), 16);
|
||||
const g = parseInt(hex.slice(2, 4), 16);
|
||||
const b = parseInt(hex.slice(4, 6), 16);
|
||||
|
||||
const mix = (channel: number, target: number, amount: number) =>
|
||||
Math.round(channel + (target - channel) * amount);
|
||||
|
||||
const hover = `rgb(${mix(r, 255, 0.12)}, ${mix(g, 255, 0.12)}, ${mix(b, 255, 0.12)})`;
|
||||
const muted = `rgba(${r}, ${g}, ${b}, 0.12)`;
|
||||
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--accent', `#${hex}`);
|
||||
root.style.setProperty('--accent-hover', hover);
|
||||
root.style.setProperty('--accent-muted', muted);
|
||||
}
|
||||
|
||||
export function AccentColorProvider({ children }: { children: ReactNode }) {
|
||||
const [accentColor, setAccentColor] = useState('#FFFFFF');
|
||||
|
||||
const refreshAccentColor = useCallback(() => {
|
||||
fetch('/api/node', { cache: 'no-store' })
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (data?.accentColor) {
|
||||
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(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshAccentColor();
|
||||
}, [refreshAccentColor]);
|
||||
|
||||
return (
|
||||
<AccentColorContext.Provider value={{ accentColor, refreshAccentColor }}>
|
||||
{children}
|
||||
</AccentColorContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAccentColor() {
|
||||
const context = useContext(AccentColorContext);
|
||||
if (!context) {
|
||||
throw new Error('useAccentColor must be used within an AccentColorProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
handle: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
isAdmin: boolean;
|
||||
loading: boolean;
|
||||
checkAdmin: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType>({
|
||||
user: null,
|
||||
isAdmin: false,
|
||||
loading: true,
|
||||
checkAdmin: async () => { },
|
||||
});
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const checkAdmin = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin/me');
|
||||
const data = await res.json();
|
||||
setIsAdmin(!!data.isAdmin);
|
||||
} catch {
|
||||
setIsAdmin(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadAuth = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/auth/me');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setUser(data.user);
|
||||
if (data.user) {
|
||||
await checkAdmin();
|
||||
}
|
||||
} else {
|
||||
setUser(null);
|
||||
}
|
||||
} catch {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAuth();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, isAdmin, loading, checkAdmin }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useAuth = () => useContext(AuthContext);
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
import { useAccentColor } from './AccentColorContext';
|
||||
|
||||
type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
interface Toast {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ToastType;
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toasts: Toast[];
|
||||
showToast: (message: string, type?: ToastType) => void;
|
||||
removeToast: (id: string) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | null>(null);
|
||||
|
||||
// Check if a color is light (needs dark text)
|
||||
function isLightColor(hex: string): boolean {
|
||||
const color = hex.replace('#', '');
|
||||
const r = parseInt(color.slice(0, 2), 16);
|
||||
const g = parseInt(color.slice(2, 4), 16);
|
||||
const b = parseInt(color.slice(4, 6), 16);
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.6;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const showToast = useCallback((message: string, type: ToastType = 'info') => {
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
setToasts(prev => [...prev, { id, message, type }]);
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, 3500);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ toasts, showToast, removeToast }}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} removeToast={removeToast} />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function ToastContainer({ toasts, removeToast }: { toasts: Toast[]; removeToast: (id: string) => void }) {
|
||||
const { accentColor } = useAccentColor();
|
||||
const needsDarkText = isLightColor(accentColor);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '24px',
|
||||
right: '24px',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
{toasts.map(toast => (
|
||||
<div
|
||||
key={toast.id}
|
||||
onClick={() => removeToast(toast.id)}
|
||||
style={{
|
||||
pointerEvents: 'auto',
|
||||
cursor: 'pointer',
|
||||
padding: '12px 16px',
|
||||
borderRadius: '8px',
|
||||
background: toast.type === 'error'
|
||||
? 'var(--danger)'
|
||||
: toast.type === 'success'
|
||||
? 'var(--accent)'
|
||||
: 'var(--background-secondary)',
|
||||
color: toast.type === 'error'
|
||||
? '#fff'
|
||||
: toast.type === 'success'
|
||||
? (needsDarkText ? '#000' : '#fff')
|
||||
: 'var(--foreground)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
maxWidth: '320px',
|
||||
animation: 'toastSlideIn 0.2s ease-out',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
<style jsx global>{`
|
||||
@keyframes toastSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user