chore: Initial commit of Opencord base state

This commit is contained in:
Jannis Braun
2026-02-18 02:49:21 +01:00
commit 4fd17084a5
124 changed files with 17955 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
const statusColors = {
online: 'bg-discord-green',
idle: 'bg-discord-yellow',
dnd: 'bg-discord-red',
offline: 'bg-gray-500',
};
export function Avatar({ src, name, size = 40, status, className = '', onClick }) {
const initials = name.charAt(0).toUpperCase();
const fontSize = size < 32 ? 'text-xs' : size < 48 ? 'text-sm' : 'text-lg';
return (_jsxs("div", { className: `relative inline-flex flex-shrink-0 ${onClick ? 'cursor-pointer' : ''} ${className}`, style: { width: size, height: size }, onClick: onClick, children: [src ? (_jsx("img", { src: src.startsWith('http') ? src : `/api/uploads/${src}`, alt: name, className: "w-full h-full rounded-full object-cover", onError: (e) => {
e.target.style.display = 'none';
const parent = e.target.parentElement;
if (parent) {
const fallback = parent.querySelector('.avatar-fallback');
if (fallback)
fallback.style.display = 'flex';
}
} })) : null, _jsx("div", { className: `avatar-fallback w-full h-full rounded-full bg-discord-blurple flex items-center justify-center ${fontSize} font-semibold text-white ${src ? 'hidden' : 'flex'}`, style: src ? { display: 'none' } : undefined, children: initials }), status && (_jsx("div", { className: `absolute -bottom-0.5 -right-0.5 rounded-full border-[3px] border-discord-bg-secondary ${statusColors[status] ?? 'bg-gray-500'}`, style: {
width: size * 0.35,
height: size * 0.35,
minWidth: 10,
minHeight: 10,
} }))] }));
}
+63
View File
@@ -0,0 +1,63 @@
import React from 'react';
interface AvatarProps {
src?: string | null;
name: string;
size?: number;
status?: 'online' | 'idle' | 'dnd' | 'offline' | null;
className?: string;
onClick?: () => void;
}
const statusColors: Record<string, string> = {
online: 'bg-discord-green',
idle: 'bg-discord-yellow',
dnd: 'bg-discord-red',
offline: 'bg-gray-500',
};
export function Avatar({ src, name, size = 40, status, className = '', onClick }: AvatarProps) {
const initials = name.charAt(0).toUpperCase();
const fontSize = size < 32 ? 'text-xs' : size < 48 ? 'text-sm' : 'text-lg';
return (
<div
className={`relative inline-flex flex-shrink-0 ${onClick ? 'cursor-pointer' : ''} ${className}`}
style={{ width: size, height: size }}
onClick={onClick}
>
{src ? (
<img
src={src.startsWith('http') ? src : `/api/uploads/${src}`}
alt={name}
className="w-full h-full rounded-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
const parent = (e.target as HTMLImageElement).parentElement;
if (parent) {
const fallback = parent.querySelector('.avatar-fallback') as HTMLElement;
if (fallback) fallback.style.display = 'flex';
}
}}
/>
) : null}
<div
className={`avatar-fallback w-full h-full rounded-full bg-discord-blurple flex items-center justify-center ${fontSize} font-semibold text-white ${src ? 'hidden' : 'flex'}`}
style={src ? { display: 'none' } : undefined}
>
{initials}
</div>
{status && (
<div
className={`absolute -bottom-0.5 -right-0.5 rounded-full border-[3px] border-discord-bg-secondary ${statusColors[status] ?? 'bg-gray-500'}`}
style={{
width: size * 0.35,
height: size * 0.35,
minWidth: 10,
minHeight: 10,
}}
/>
)}
</div>
);
}
@@ -0,0 +1,47 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useEffect, useRef, useState } from 'react';
export function ContextMenu({ items, children }) {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const menuRef = useRef(null);
const handleContextMenu = (e) => {
e.preventDefault();
setPosition({ x: e.clientX, y: e.clientY });
setIsOpen(true);
};
useEffect(() => {
const handleClick = () => setIsOpen(false);
const handleScroll = () => setIsOpen(false);
if (isOpen) {
document.addEventListener('click', handleClick);
document.addEventListener('scroll', handleScroll, true);
return () => {
document.removeEventListener('click', handleClick);
document.removeEventListener('scroll', handleScroll, true);
};
}
}, [isOpen]);
// Adjust position to keep menu in viewport
useEffect(() => {
if (isOpen && menuRef.current) {
const rect = menuRef.current.getBoundingClientRect();
const newPosition = { ...position };
if (rect.right > window.innerWidth) {
newPosition.x = window.innerWidth - rect.width - 8;
}
if (rect.bottom > window.innerHeight) {
newPosition.y = window.innerHeight - rect.height - 8;
}
if (newPosition.x !== position.x || newPosition.y !== position.y) {
setPosition(newPosition);
}
}
}, [isOpen, position]);
return (_jsxs(_Fragment, { children: [_jsx("div", { onContextMenu: handleContextMenu, children: children }), isOpen && (_jsx("div", { ref: menuRef, className: "fixed z-50 min-w-[180px] py-1.5 bg-[#111214] rounded-md shadow-xl border border-gray-800 animate-fade-in", style: { left: position.x, top: position.y }, children: items.map((item, i) => (_jsxs("button", { className: `w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 ${item.danger
? 'text-discord-red hover:bg-discord-red hover:text-white'
: 'text-discord-text-secondary hover:bg-discord-blurple hover:text-white'}`, style: { width: 'calc(100% - 12px)' }, onClick: (e) => {
e.stopPropagation();
item.onClick();
setIsOpen(false);
}, children: [item.icon && _jsx("span", { className: "w-4 h-4", children: item.icon }), item.label] }, i))) }))] }));
}
@@ -0,0 +1,91 @@
import React, { useEffect, useRef, useState } from 'react';
interface ContextMenuItem {
label: string;
onClick: () => void;
danger?: boolean;
icon?: React.ReactNode;
}
interface ContextMenuProps {
items: ContextMenuItem[];
children: React.ReactNode;
}
export function ContextMenu({ items, children }: ContextMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 });
const menuRef = useRef<HTMLDivElement>(null);
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
setPosition({ x: e.clientX, y: e.clientY });
setIsOpen(true);
};
useEffect(() => {
const handleClick = () => setIsOpen(false);
const handleScroll = () => setIsOpen(false);
if (isOpen) {
document.addEventListener('click', handleClick);
document.addEventListener('scroll', handleScroll, true);
return () => {
document.removeEventListener('click', handleClick);
document.removeEventListener('scroll', handleScroll, true);
};
}
}, [isOpen]);
// Adjust position to keep menu in viewport
useEffect(() => {
if (isOpen && menuRef.current) {
const rect = menuRef.current.getBoundingClientRect();
const newPosition = { ...position };
if (rect.right > window.innerWidth) {
newPosition.x = window.innerWidth - rect.width - 8;
}
if (rect.bottom > window.innerHeight) {
newPosition.y = window.innerHeight - rect.height - 8;
}
if (newPosition.x !== position.x || newPosition.y !== position.y) {
setPosition(newPosition);
}
}
}, [isOpen, position]);
return (
<>
<div onContextMenu={handleContextMenu}>{children}</div>
{isOpen && (
<div
ref={menuRef}
className="fixed z-50 min-w-[180px] py-1.5 bg-[#111214] rounded-md shadow-xl border border-gray-800 animate-fade-in"
style={{ left: position.x, top: position.y }}
>
{items.map((item, i) => (
<button
key={i}
className={`w-full text-left px-2 py-1.5 mx-1.5 text-sm rounded-sm flex items-center gap-2 ${
item.danger
? 'text-discord-red hover:bg-discord-red hover:text-white'
: 'text-discord-text-secondary hover:bg-discord-blurple hover:text-white'
}`}
style={{ width: 'calc(100% - 12px)' }}
onClick={(e) => {
e.stopPropagation();
item.onClick();
setIsOpen(false);
}}
>
{item.icon && <span className="w-4 h-4">{item.icon}</span>}
{item.label}
</button>
))}
</div>
)}
</>
);
}
@@ -0,0 +1,4 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
export function LoadingSpinner({ size = 40, className = '' }) {
return (_jsx("div", { className: `flex items-center justify-center ${className}`, children: _jsxs("svg", { className: "animate-spin", width: size, height: size, viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" })] }) }));
}
@@ -0,0 +1,34 @@
import React from 'react';
interface LoadingSpinnerProps {
size?: number;
className?: string;
}
export function LoadingSpinner({ size = 40, className = '' }: LoadingSpinnerProps) {
return (
<div className={`flex items-center justify-center ${className}`}>
<svg
className="animate-spin"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useCallback } from 'react';
export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md' }) {
const handleKeyDown = useCallback((e) => {
if (e.key === 'Escape') {
onClose();
}
}, [onClose]);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [isOpen, handleKeyDown]);
if (!isOpen)
return null;
return (_jsxs("div", { className: "fixed inset-0 z-50 flex items-center justify-center animate-fade-in", children: [_jsx("div", { className: "absolute inset-0 bg-black/70", onClick: onClose }), _jsxs("div", { className: `relative ${maxWidth} w-full mx-4 bg-discord-bg-primary rounded-lg shadow-xl animate-slide-up`, children: [title && (_jsxs("div", { className: "flex items-center justify-between px-4 pt-4", children: [_jsx("h2", { className: "text-xl font-bold text-discord-text-primary", children: title }), _jsx("button", { onClick: onClose, className: "text-discord-text-muted hover:text-discord-text-primary transition-colors p-1", children: _jsx("svg", { width: "24", height: "24", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" }) }) })] })), _jsx("div", { className: "p-4", children: children })] })] }));
}
+53
View File
@@ -0,0 +1,53 @@
import React, { useEffect, useCallback } from 'react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
maxWidth?: string;
}
export function Modal({ isOpen, onClose, title, children, maxWidth = 'max-w-md' }: ModalProps) {
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose();
}
}, [onClose]);
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}
}, [isOpen, handleKeyDown]);
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center animate-fade-in">
<div
className="absolute inset-0 bg-black/70"
onClick={onClose}
/>
<div className={`relative ${maxWidth} w-full mx-4 bg-discord-bg-primary rounded-lg shadow-xl animate-slide-up`}>
{title && (
<div className="flex items-center justify-between px-4 pt-4">
<h2 className="text-xl font-bold text-discord-text-primary">{title}</h2>
<button
onClick={onClose}
className="text-discord-text-muted hover:text-discord-text-primary transition-colors p-1"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
</svg>
</button>
</div>
)}
<div className="p-4">
{children}
</div>
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useRef, useEffect } from 'react';
export function Tooltip({ content, children, position = 'right', delay = 200 }) {
const [isVisible, setIsVisible] = useState(false);
const timeoutRef = useRef();
const show = () => {
timeoutRef.current = setTimeout(() => setIsVisible(true), delay);
};
const hide = () => {
if (timeoutRef.current)
clearTimeout(timeoutRef.current);
setIsVisible(false);
};
useEffect(() => {
return () => {
if (timeoutRef.current)
clearTimeout(timeoutRef.current);
};
}, []);
const positionClasses = {
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
};
return (_jsxs("div", { className: "relative inline-flex", onMouseEnter: show, onMouseLeave: hide, children: [children, isVisible && (_jsx("div", { className: `absolute z-50 px-3 py-1.5 text-sm font-medium text-white bg-gray-900 rounded-md shadow-lg whitespace-nowrap pointer-events-none ${positionClasses[position]}`, children: content }))] }));
}
@@ -0,0 +1,48 @@
import React, { useState, useRef, useEffect } from 'react';
interface TooltipProps {
content: string;
children: React.ReactNode;
position?: 'top' | 'right' | 'bottom' | 'left';
delay?: number;
}
export function Tooltip({ content, children, position = 'right', delay = 200 }: TooltipProps) {
const [isVisible, setIsVisible] = useState(false);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const show = () => {
timeoutRef.current = setTimeout(() => setIsVisible(true), delay);
};
const hide = () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
setIsVisible(false);
};
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
const positionClasses: Record<string, string> = {
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
};
return (
<div className="relative inline-flex" onMouseEnter={show} onMouseLeave={hide}>
{children}
{isVisible && (
<div
className={`absolute z-50 px-3 py-1.5 text-sm font-medium text-white bg-gray-900 rounded-md shadow-lg whitespace-nowrap pointer-events-none ${positionClasses[position]}`}
>
{content}
</div>
)}
</div>
);
}