chore: Initial commit of Opencord base state
This commit is contained in:
@@ -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,
|
||||
} }))] }));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 })] })] }));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user