Autocommit test+ui polish

This commit is contained in:
mixa
2026-03-06 21:32:58 +03:00
parent 299ff65afd
commit dd89a5cf2d
19 changed files with 1115 additions and 553 deletions

View File

@@ -1,4 +1,4 @@
.PHONY: build-all frontend-build sync-web backend-build run-all up down run-local run-backend run-frontend clean .PHONY: build-all frontend-build sync-web backend-build run-all up down run-local run-backend run-frontend auto-commit push clean
build-all: frontend-build sync-web backend-build build-all: frontend-build sync-web backend-build
@@ -33,5 +33,12 @@ run-backend:
run-frontend: run-frontend:
bun --cwd frontend dev bun --cwd frontend dev
auto-commit:
git add -A
git commit -m "$(if $(MSG),$(MSG),chore: auto commit $$(date '+%Y-%m-%d %H:%M:%S'))"
push:
git push $(if $(REMOTE),$(REMOTE),origin) $(if $(BRANCH),$(BRANCH),$$(git branch --show-current))
clean: clean:
rm -rf dist backend/web/dist frontend/dist rm -rf dist backend/web/dist frontend/dist

View File

@@ -21,7 +21,7 @@ Drive-like app with Go backend + Bun/React frontend.
- UI uses Radix-based components (shadcn-style wrappers) - UI uses Radix-based components (shadcn-style wrappers)
- Auto language detection with English/Russian translations - Auto language detection with English/Russian translations
- Optional per-user FTP and FTPS access (same FileZ usernames/passwords) - Optional per-user FTP and FTPS access (same FileZ usernames/passwords)
- Optional Google OAuth login (auto-provisions user on first sign-in) - Optional Google OAuth login (auto-provisions user on first sign-in or links to existing signed-in user)
## Task-style commands ## Task-style commands

View File

@@ -203,6 +203,7 @@ func main() {
protected.Use(s.authMiddleware) protected.Use(s.authMiddleware)
protected.HandleFunc("/auth/me", s.handleMe).Methods(http.MethodGet) protected.HandleFunc("/auth/me", s.handleMe).Methods(http.MethodGet)
protected.HandleFunc("/user/preferences", s.handleSetPreferences).Methods(http.MethodPost) protected.HandleFunc("/user/preferences", s.handleSetPreferences).Methods(http.MethodPost)
protected.HandleFunc("/user/google/link/start", s.handleGoogleLinkStart).Methods(http.MethodGet)
protected.HandleFunc("/user/protocols", s.handleUserProtocols).Methods(http.MethodGet) protected.HandleFunc("/user/protocols", s.handleUserProtocols).Methods(http.MethodGet)
protected.HandleFunc("/files", s.handleListFiles).Methods(http.MethodGet) protected.HandleFunc("/files", s.handleListFiles).Methods(http.MethodGet)
protected.HandleFunc("/files/upload", s.handleUpload).Methods(http.MethodPost) protected.HandleFunc("/files/upload", s.handleUpload).Methods(http.MethodPost)

View File

@@ -8,9 +8,14 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"github.com/golang-jwt/jwt/v5"
) )
const googleOAuthStateCookie = "google_oauth_state" const (
googleOAuthLoginStateCookie = "google_oauth_state"
googleOAuthLinkStateCookie = "google_oauth_link_state"
)
type googleTokenResponse struct { type googleTokenResponse struct {
AccessToken string `json:"access_token"` AccessToken string `json:"access_token"`
@@ -33,7 +38,39 @@ func (s *Server) handleGoogleAuthStart(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusInternalServerError, "failed to initialize oauth") writeErr(w, http.StatusInternalServerError, "failed to initialize oauth")
return return
} }
setCookie(w, googleOAuthStateCookie, state, 600, s.config.CookieSecure) setCookie(w, googleOAuthLoginStateCookie, state, 600, s.config.CookieSecure)
clearCookie(w, googleOAuthLinkStateCookie, s.config.CookieSecure)
u, err := url.Parse(s.config.GoogleAuthURL)
if err != nil {
writeErr(w, http.StatusInternalServerError, "invalid google auth config")
return
}
q := u.Query()
q.Set("client_id", strings.TrimSpace(s.config.GoogleClientID))
q.Set("redirect_uri", s.googleRedirectURL(r))
q.Set("response_type", "code")
q.Set("scope", "openid email profile")
q.Set("state", state)
q.Set("prompt", "select_account")
u.RawQuery = q.Encode()
http.Redirect(w, r, u.String(), http.StatusFound)
}
func (s *Server) handleGoogleLinkStart(w http.ResponseWriter, r *http.Request) {
if !s.config.GoogleAuthEnabled {
writeErr(w, http.StatusNotFound, "google auth is disabled")
return
}
state, err := randomToken()
if err != nil {
writeErr(w, http.StatusInternalServerError, "failed to initialize oauth")
return
}
setCookie(w, googleOAuthLinkStateCookie, state, 600, s.config.CookieSecure)
clearCookie(w, googleOAuthLoginStateCookie, s.config.CookieSecure)
u, err := url.Parse(s.config.GoogleAuthURL) u, err := url.Parse(s.config.GoogleAuthURL)
if err != nil { if err != nil {
@@ -70,12 +107,11 @@ func (s *Server) handleGoogleAuthCallback(w http.ResponseWriter, r *http.Request
} }
state := strings.TrimSpace(r.URL.Query().Get("state")) state := strings.TrimSpace(r.URL.Query().Get("state"))
stateCookie, err := r.Cookie(googleOAuthStateCookie) flow, uid, ok := s.resolveGoogleOAuthFlow(w, r, state)
if err != nil || stateCookie == nil || stateCookie.Value == "" || subtleConstantTimeEq(stateCookie.Value, state) == 0 { if !ok {
writeErr(w, http.StatusUnauthorized, "invalid oauth state") writeErr(w, http.StatusUnauthorized, "invalid oauth state")
return return
} }
clearCookie(w, googleOAuthStateCookie, s.config.CookieSecure)
token, err := s.exchangeGoogleCode(r.Context(), code, s.googleRedirectURL(r)) token, err := s.exchangeGoogleCode(r.Context(), code, s.googleRedirectURL(r))
if err != nil { if err != nil {
@@ -91,6 +127,17 @@ func (s *Server) handleGoogleAuthCallback(w http.ResponseWriter, r *http.Request
return return
} }
if flow == "link" {
if err := s.linkGoogleSubToUser(uid, info.Sub); err != nil {
log.Printf("auth.google.failed ip=%q reason=%q", clientIP(r), "user_link_failed")
writeErr(w, http.StatusUnauthorized, "google account link failed")
return
}
log.Printf("auth.google.linked user_id=%d ip=%q", uid, clientIP(r))
http.Redirect(w, r, "/drive", http.StatusFound)
return
}
user, err := s.findOrCreateGoogleUser(info.Sub, info.Email) user, err := s.findOrCreateGoogleUser(info.Sub, info.Email)
if err != nil { if err != nil {
log.Printf("auth.google.failed ip=%q reason=%q", clientIP(r), "user_provision_failed") log.Printf("auth.google.failed ip=%q reason=%q", clientIP(r), "user_provision_failed")
@@ -108,6 +155,83 @@ func (s *Server) handleGoogleAuthCallback(w http.ResponseWriter, r *http.Request
http.Redirect(w, r, "/drive", http.StatusFound) http.Redirect(w, r, "/drive", http.StatusFound)
} }
func (s *Server) resolveGoogleOAuthFlow(w http.ResponseWriter, r *http.Request, state string) (string, int64, bool) {
state = strings.TrimSpace(state)
if state == "" {
return "", 0, false
}
if linkCookie, err := r.Cookie(googleOAuthLinkStateCookie); err == nil && linkCookie != nil && linkCookie.Value != "" {
if subtleConstantTimeEq(linkCookie.Value, state) == 1 {
clearCookie(w, googleOAuthLinkStateCookie, s.config.CookieSecure)
uid, uidErr := s.userIDFromAccessCookie(r)
if uidErr != nil {
return "", 0, false
}
return "link", uid, true
}
}
if loginCookie, err := r.Cookie(googleOAuthLoginStateCookie); err == nil && loginCookie != nil && loginCookie.Value != "" {
if subtleConstantTimeEq(loginCookie.Value, state) == 1 {
clearCookie(w, googleOAuthLoginStateCookie, s.config.CookieSecure)
return "login", 0, true
}
}
return "", 0, false
}
func (s *Server) userIDFromAccessCookie(r *http.Request) (int64, error) {
cookie, err := r.Cookie("access_token")
if err != nil || cookie == nil || cookie.Value == "" {
return 0, fmt.Errorf("missing access token")
}
claims := &AccessClaims{}
tkn, err := jwt.ParseWithClaims(cookie.Value, claims, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
return []byte(s.config.JWTSecret), nil
})
if err != nil || !tkn.Valid {
return 0, fmt.Errorf("invalid access token")
}
if claims.UserID <= 0 {
return 0, fmt.Errorf("invalid access token claims")
}
return claims.UserID, nil
}
func (s *Server) linkGoogleSubToUser(userID int64, googleSub string) error {
googleSub = strings.TrimSpace(googleSub)
if userID <= 0 || googleSub == "" {
return fmt.Errorf("invalid link request")
}
if _, err := s.findUser(userID); err != nil {
return err
}
if existing, err := s.findUserByGoogleSub(googleSub); err == nil {
if existing.ID != userID {
return fmt.Errorf("google account already linked")
}
return nil
} else if !isNoRows(err) {
return err
}
if err := s.orm.updateGoogleSub(userID, googleSub); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
return fmt.Errorf("google account already linked")
}
return err
}
return nil
}
func (s *Server) googleRedirectURL(r *http.Request) string { func (s *Server) googleRedirectURL(r *http.Request) string {
if v := strings.TrimSpace(s.config.GoogleRedirectURL); v != "" { if v := strings.TrimSpace(s.config.GoogleRedirectURL); v != "" {
return v return v

View File

@@ -60,7 +60,7 @@ func TestGoogleOAuthCallbackCreatesSessionAndUser(t *testing.T) {
if startRec.Code != http.StatusFound { if startRec.Code != http.StatusFound {
t.Fatalf("start status = %d, want %d", startRec.Code, http.StatusFound) t.Fatalf("start status = %d, want %d", startRec.Code, http.StatusFound)
} }
stateCookie := cookieByName(startRec.Result().Cookies(), googleOAuthStateCookie) stateCookie := cookieByName(startRec.Result().Cookies(), googleOAuthLoginStateCookie)
if stateCookie == nil || stateCookie.Value == "" { if stateCookie == nil || stateCookie.Value == "" {
t.Fatal("missing oauth state cookie") t.Fatal("missing oauth state cookie")
} }
@@ -106,3 +106,84 @@ func TestGoogleOAuthCallbackCreatesSessionAndUser(t *testing.T) {
t.Fatalf("google_sub = %q, want %q", googleSub, "google-sub-1") t.Fatalf("google_sub = %q, want %q", googleSub, "google-sub-1")
} }
} }
func TestGoogleOAuthCallbackLinksGoogleToExistingUser(t *testing.T) {
t.Parallel()
mux := http.NewServeMux()
provider := httptest.NewServer(mux)
defer provider.Close()
mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "google-link-token"})
})
mux.HandleFunc("/userinfo", func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"sub": "google-sub-link-1",
"email": "other@example.com",
"email_verified": true,
})
})
s := makeTestServer(t, func(cfg *Config) {
cfg.GoogleAuthEnabled = true
cfg.GoogleClientID = "client-id"
cfg.GoogleClientSecret = "client-secret"
cfg.GoogleAuthURL = provider.URL + "/auth"
cfg.GoogleTokenURL = provider.URL + "/token"
cfg.GoogleUserInfoURL = provider.URL + "/userinfo"
})
user, err := s.createUser("alice", "password123", "dracula", "auto")
if err != nil {
t.Fatalf("create user failed: %v", err)
}
loginReq := httptest.NewRequest(http.MethodGet, "/api/auth/login-link", nil)
loginRec := httptest.NewRecorder()
if err := s.issueUserSession(loginRec, loginReq, user.ID); err != nil {
t.Fatalf("issueUserSession failed: %v", err)
}
accessCookie := cookieByName(loginRec.Result().Cookies(), "access_token")
if accessCookie == nil || accessCookie.Value == "" {
t.Fatal("missing access cookie")
}
startReq := httptest.NewRequest(http.MethodGet, "/api/user/google/link/start", nil)
startReq.Host = "file.example.com"
startReq.AddCookie(accessCookie)
startRec := httptest.NewRecorder()
s.handleGoogleLinkStart(startRec, startReq)
if startRec.Code != http.StatusFound {
t.Fatalf("link start status = %d, want %d", startRec.Code, http.StatusFound)
}
stateCookie := cookieByName(startRec.Result().Cookies(), googleOAuthLinkStateCookie)
if stateCookie == nil || stateCookie.Value == "" {
t.Fatal("missing link state cookie")
}
cbReq := httptest.NewRequest(http.MethodGet, "/api/auth/google/callback?code=ok-code&state="+url.QueryEscape(stateCookie.Value), nil)
cbReq.Host = "file.example.com"
cbReq.AddCookie(stateCookie)
cbReq.AddCookie(accessCookie)
cbRec := httptest.NewRecorder()
s.handleGoogleAuthCallback(cbRec, cbReq)
if cbRec.Code != http.StatusFound {
t.Fatalf("callback status = %d, want %d", cbRec.Code, http.StatusFound)
}
if got := cbRec.Header().Get("Location"); got != "/drive" {
t.Fatalf("callback redirect = %q, want %q", got, "/drive")
}
var googleSub string
err = s.db.QueryRow(`SELECT COALESCE(google_sub, '') FROM users WHERE id = ?`, user.ID).Scan(&googleSub)
if err != nil {
t.Fatalf("query user google_sub failed: %v", err)
}
if strings.TrimSpace(googleSub) != "google-sub-link-1" {
t.Fatalf("google_sub = %q, want %q", googleSub, "google-sub-link-1")
}
}

View File

@@ -29,7 +29,7 @@ import { Tabs, TabsList, TabsTrigger } from './components/ui/tabs'
import { Textarea } from './components/ui/textarea' import { Textarea } from './components/ui/textarea'
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './components/ui/context-menu' import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger } from './components/ui/context-menu'
type Theme = 'dracula' | 'nord' | 'monokai' | 'solarized' | 'github' type Theme = 'dracula' | 'nord' | 'monokai' | 'solarized' | 'github' | 'bureau'
type ColorMode = 'auto' | 'light' | 'dark' type ColorMode = 'auto' | 'light' | 'dark'
type Lang = 'en' | 'ru' | 'de' type Lang = 'en' | 'ru' | 'de'
type Route = 'landing' | 'drive' | 'admin' type Route = 'landing' | 'drive' | 'admin'
@@ -63,7 +63,7 @@ function resolveModuleValue<T>(mod: unknown): T | null {
return (firstFn as T | undefined) ?? null return (firstFn as T | undefined) ?? null
} }
const themeOptions: Theme[] = ['dracula', 'nord', 'monokai', 'solarized', 'github'] const themeOptions: Theme[] = ['dracula', 'nord', 'monokai', 'solarized', 'github', 'bureau']
const modeOptions: ColorMode[] = ['auto', 'light', 'dark'] const modeOptions: ColorMode[] = ['auto', 'light', 'dark']
const archiveOptions: ArchiveFormat[] = ['zip', 'rar', 'tar.gz', 'lz4'] const archiveOptions: ArchiveFormat[] = ['zip', 'rar', 'tar.gz', 'lz4']
const shareTTLPresets: Array<{ value: string; label: string }> = [ const shareTTLPresets: Array<{ value: string; label: string }> = [
@@ -93,6 +93,7 @@ const dict: Record<Lang, Record<string, string>> = {
configuredFromEnv: 'Admin credentials are configured in backend environment.', configuredFromEnv: 'Admin credentials are configured in backend environment.',
signIn: 'Sign in', signIn: 'Sign in',
signInGoogle: 'Continue with Google', signInGoogle: 'Continue with Google',
linkGoogle: 'Link Google account',
signInAdmin: 'Sign in as admin', signInAdmin: 'Sign in as admin',
loading: 'Loading', loading: 'Loading',
username: 'Username', username: 'Username',
@@ -116,6 +117,7 @@ const dict: Record<Lang, Record<string, string>> = {
limit: 'Max downloads (blank = unlimited)', limit: 'Max downloads (blank = unlimited)',
accountSubtitle: 'Workspace', accountSubtitle: 'Workspace',
settings: 'Settings', settings: 'Settings',
language: 'Language',
theme: 'Color scheme', theme: 'Color scheme',
mode: 'Mode', mode: 'Mode',
archiveFormat: 'Folder download format', archiveFormat: 'Folder download format',
@@ -128,6 +130,7 @@ const dict: Record<Lang, Record<string, string>> = {
monokai: 'Monokai', monokai: 'Monokai',
solarized: 'Solarized', solarized: 'Solarized',
github: 'GitHub', github: 'GitHub',
bureau: 'Bureau',
search: 'Search in this folder', search: 'Search in this folder',
items: 'items', items: 'items',
empty: 'No files in this folder', empty: 'No files in this folder',
@@ -179,6 +182,14 @@ const dict: Record<Lang, Record<string, string>> = {
optional: 'optional', optional: 'optional',
useAccountPassword: 'Use your account password', useAccountPassword: 'Use your account password',
copyUri: 'Copy URI', copyUri: 'Copy URI',
all: 'all',
shellTagline: 'Private storage, sharing, and admin controls in one interface.',
controlNode: 'Control node',
authFeatureDesc: 'Hardened authentication and session handling.',
storageFeatureDesc: 'Private storage lanes for each account.',
shareFeatureDesc: 'Expiring links with resumable downloads.',
remoteAccessDesc: 'Direct transfer access for desktop clients.',
controlSurface: 'FileZ control surface',
}, },
de: { de: {
brand: 'FileZ', brand: 'FileZ',
@@ -194,6 +205,7 @@ const dict: Record<Lang, Record<string, string>> = {
configuredFromEnv: 'Admin-Zugangsdaten werden im Backend-Environment konfiguriert.', configuredFromEnv: 'Admin-Zugangsdaten werden im Backend-Environment konfiguriert.',
signIn: 'Anmelden', signIn: 'Anmelden',
signInGoogle: 'Mit Google fortfahren', signInGoogle: 'Mit Google fortfahren',
linkGoogle: 'Google-Konto verknupfen',
signInAdmin: 'Als Admin anmelden', signInAdmin: 'Als Admin anmelden',
loading: 'Laden', loading: 'Laden',
username: 'Benutzername', username: 'Benutzername',
@@ -217,6 +229,7 @@ const dict: Record<Lang, Record<string, string>> = {
limit: 'Max. Downloads (leer = unbegrenzt)', limit: 'Max. Downloads (leer = unbegrenzt)',
accountSubtitle: 'Arbeitsbereich', accountSubtitle: 'Arbeitsbereich',
settings: 'Einstellungen', settings: 'Einstellungen',
language: 'Sprache',
theme: 'Farbschema', theme: 'Farbschema',
mode: 'Modus', mode: 'Modus',
archiveFormat: 'Format fur Ordner-Download', archiveFormat: 'Format fur Ordner-Download',
@@ -229,6 +242,7 @@ const dict: Record<Lang, Record<string, string>> = {
monokai: 'Monokai', monokai: 'Monokai',
solarized: 'Solarized', solarized: 'Solarized',
github: 'GitHub', github: 'GitHub',
bureau: 'Bureau',
search: 'In diesem Ordner suchen', search: 'In diesem Ordner suchen',
items: 'Elemente', items: 'Elemente',
empty: 'Keine Dateien in diesem Ordner', empty: 'Keine Dateien in diesem Ordner',
@@ -280,6 +294,14 @@ const dict: Record<Lang, Record<string, string>> = {
optional: 'optional', optional: 'optional',
useAccountPassword: 'Verwende dein Kontopasswort', useAccountPassword: 'Verwende dein Kontopasswort',
copyUri: 'URI kopieren', copyUri: 'URI kopieren',
all: 'alle',
shellTagline: 'Private Speicherung, Freigaben und Admin-Steuerung in einer Oberflache.',
controlNode: 'Kontrollknoten',
authFeatureDesc: 'Geharte Authentifizierung und Sitzungsverwaltung.',
storageFeatureDesc: 'Private Speicherbereiche fur jedes Konto.',
shareFeatureDesc: 'Ablaufende Links mit fortsetzbaren Downloads.',
remoteAccessDesc: 'Direkter Transferzugang fur Desktop-Clients.',
controlSurface: 'FileZ Kontrolloberflache',
}, },
ru: { ru: {
brand: 'FileZ', brand: 'FileZ',
@@ -295,6 +317,7 @@ const dict: Record<Lang, Record<string, string>> = {
configuredFromEnv: 'Данные администратора задаются в окружении backend.', configuredFromEnv: 'Данные администратора задаются в окружении backend.',
signIn: 'Войти', signIn: 'Войти',
signInGoogle: 'Продолжить через Google', signInGoogle: 'Продолжить через Google',
linkGoogle: 'Привязать Google аккаунт',
signInAdmin: 'Войти как админ', signInAdmin: 'Войти как админ',
loading: 'Загрузка', loading: 'Загрузка',
username: 'Логин', username: 'Логин',
@@ -318,6 +341,7 @@ const dict: Record<Lang, Record<string, string>> = {
limit: 'Макс. скачиваний (пусто = без лимита)', limit: 'Макс. скачиваний (пусто = без лимита)',
accountSubtitle: 'Рабочее пространство', accountSubtitle: 'Рабочее пространство',
settings: 'Настройки', settings: 'Настройки',
language: 'Язык',
theme: 'Цветовая схема', theme: 'Цветовая схема',
mode: 'Режим', mode: 'Режим',
archiveFormat: 'Формат скачивания папок', archiveFormat: 'Формат скачивания папок',
@@ -330,6 +354,7 @@ const dict: Record<Lang, Record<string, string>> = {
monokai: 'Monokai', monokai: 'Monokai',
solarized: 'Solarized', solarized: 'Solarized',
github: 'GitHub', github: 'GitHub',
bureau: 'Bureau',
search: 'Поиск в текущей папке', search: 'Поиск в текущей папке',
items: 'объектов', items: 'объектов',
empty: 'В этой папке нет файлов', empty: 'В этой папке нет файлов',
@@ -381,6 +406,14 @@ const dict: Record<Lang, Record<string, string>> = {
optional: 'опционально', optional: 'опционально',
useAccountPassword: 'Используйте пароль аккаунта', useAccountPassword: 'Используйте пароль аккаунта',
copyUri: 'Копировать URI', copyUri: 'Копировать URI',
all: 'все',
shellTagline: 'Приватное хранилище, шаринг и админ-панель в одном интерфейсе.',
controlNode: 'Контрольный узел',
authFeatureDesc: 'Защищенная аутентификация и управление сессиями.',
storageFeatureDesc: 'Приватные зоны хранения для каждого аккаунта.',
shareFeatureDesc: 'Ссылки с истечением срока и докачкой.',
remoteAccessDesc: 'Прямой доступ для настольных клиентов.',
controlSurface: 'Контрольная панель FileZ',
}, },
} }
@@ -502,6 +535,29 @@ function KindIcon({ file }: { file: FileEntry }) {
return <FileIcon className="h-4 w-4 text-slate-500" /> return <FileIcon className="h-4 w-4 text-slate-500" />
} }
function GoogleIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
<path
fill="#4285F4"
d="M21.805 10.023h-9.18v3.955h5.273c-.227 1.248-.91 2.307-1.94 3.018v2.504h3.128c1.83-1.684 2.847-4.168 2.847-7.477 0-.684-.061-1.342-.128-2z"
/>
<path
fill="#34A853"
d="M12.625 22c2.565 0 4.712-.85 6.283-2.304l-3.128-2.504c-.868.582-1.979.927-3.155.927-2.427 0-4.483-1.64-5.219-3.847H4.175v2.574A9.498 9.498 0 0 0 12.625 22z"
/>
<path
fill="#FBBC05"
d="M7.406 14.272A5.71 5.71 0 0 1 7.114 12c0-.788.136-1.552.292-2.272V7.154H4.175A9.498 9.498 0 0 0 3.114 12c0 1.733.414 3.372 1.061 4.846l3.231-2.574z"
/>
<path
fill="#EA4335"
d="M12.625 5.88c1.396 0 2.648.48 3.636 1.422l2.726-2.726C17.33 3.033 15.183 2 12.625 2a9.498 9.498 0 0 0-8.45 5.154l3.231 2.574c.736-2.207 2.792-3.848 5.219-3.848z"
/>
</svg>
)
}
async function api<T>(url: string, init?: RequestInit, allowRefresh = true): Promise<T> { async function api<T>(url: string, init?: RequestInit, allowRefresh = true): Promise<T> {
const response = await fetch(url, { const response = await fetch(url, {
credentials: 'include', credentials: 'include',
@@ -870,6 +926,10 @@ export default function App() {
window.location.href = '/api/auth/google/start' window.location.href = '/api/auth/google/start'
} }
function linkGoogleAccount() {
window.location.href = '/api/user/google/link/start'
}
async function upload(list: FileList | null, useRelative = false) { async function upload(list: FileList | null, useRelative = false) {
if (!list?.length) return if (!list?.length) return
const junkNames = new Set(['.ds_store', 'thumbs.db', 'desktop.ini']) const junkNames = new Set(['.ds_store', 'thumbs.db', 'desktop.ini'])
@@ -1155,6 +1215,8 @@ export default function App() {
return out return out
}, [visibleFiles]) }, [visibleFiles])
const routeLabel = route === 'admin' ? '/admin' : route === 'drive' ? '/drive' : '/'
function selectVisibleRange(toPath: string, keepExisting = true) { function selectVisibleRange(toPath: string, keepExisting = true) {
const anchor = selectionAnchorPath || toPath const anchor = selectionAnchorPath || toPath
const fromIdx = visiblePathIndex[anchor] const fromIdx = visiblePathIndex[anchor]
@@ -1177,79 +1239,94 @@ export default function App() {
} }
const authLoadingCard = ( const authLoadingCard = (
<Card className="control-shell mx-auto max-w-md bg-card/95"> <Card className="control-shell mx-auto max-w-md overflow-hidden bg-card/95">
<CardContent className="flex min-h-[220px] flex-col items-center justify-center gap-3 p-6"> <CardContent className="flex min-h-[280px] flex-col items-center justify-center gap-4 p-10">
<div className="h-9 w-9 animate-spin border-2 border-border border-t-primary" aria-hidden="true" /> <div className="h-12 w-12 animate-spin border-[3px] border-border border-t-primary" aria-hidden="true" />
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{t('loading')}...</p> <p className="text-xs font-black uppercase tracking-[0.22em] text-muted-foreground">{t('loading')}...</p>
</CardContent> </CardContent>
</Card> </Card>
) )
return ( const driveScrollLocked = route === 'drive' && Boolean(user)
<main className="relative min-h-screen overflow-hidden p-4 md:p-6">
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(180deg,transparent_0%,hsl(var(--primary)/0.06)_100%)]" />
<div className="pointer-events-none absolute inset-0 opacity-30 [background:repeating-linear-gradient(0deg,transparent_0,transparent_2px,hsla(0,0%,100%,0.03)_3px)]" />
<div className="relative z-10 mx-auto max-w-[1660px] space-y-5"> return (
<Card className="control-shell border-primary/35 bg-card/95"> <main className={`relative overflow-hidden p-4 md:p-6 ${driveScrollLocked ? 'h-screen' : 'min-h-screen'}`}>
<CardContent className="flex flex-wrap items-center justify-between gap-3 p-4 md:p-5"> <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_right,hsl(var(--primary)/0.14),transparent_36%)]" />
<div className="flex items-center gap-3"> <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_bottom_left,hsl(var(--surface-pop)/0.1),transparent_34%)]" />
<div className="border-2 border-primary bg-primary/15 p-2 text-primary"><HardDriveUpload className="h-5 w-5" /></div>
<div> <div className={`relative z-10 mx-auto max-w-[1880px] ${driveScrollLocked ? 'flex h-full flex-col gap-6' : 'space-y-6'}`}>
<p className="font-display text-lg font-semibold">{t('brand')}</p> <Card className="control-shell overflow-hidden bg-card/95">
<p className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">{route === 'admin' ? '/admin' : route === 'drive' ? '/drive' : '/'}</p> <CardContent className="flex flex-col gap-5 p-6 lg:flex-row lg:items-center lg:justify-between">
<div className="flex items-start gap-4">
<div className="flex h-16 w-16 items-center justify-center border-[3px] border-border bg-primary text-primary-foreground shadow-[6px_6px_0_hsl(var(--shadow-strong))]">
<HardDriveUpload className="h-6 w-6" />
</div>
<div className="space-y-2">
<p className="font-display text-4xl">{t('brand')}</p>
<p className="max-w-3xl text-sm font-semibold uppercase tracking-[0.16em] text-muted-foreground">
{t('shellTagline')}
</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex flex-wrap items-center gap-3">
<Tabs value={route === 'landing' ? 'drive' : route} onValueChange={(v) => navigate(v as Route)}> <Tabs value={route === 'landing' ? 'drive' : route} onValueChange={(v) => navigate(v as Route)}>
<TabsList> <TabsList>
<TabsTrigger value="drive">{t('drive')}</TabsTrigger> <TabsTrigger value="drive">{t('drive')}</TabsTrigger>
<TabsTrigger value="admin">{t('admin')}</TabsTrigger> <TabsTrigger value="admin">{t('admin')}</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
{user ? ( <Button
<Button variant="outline" size="sm" onClick={() => setSettingsOpen(true)}> variant="outline"
<Settings2 className="mr-1 h-4 w-4" /> {t('settings')} size="sm"
className="h-[54px] w-[54px] shrink-0 p-0 shadow-[6px_6px_0_hsl(var(--shadow-strong))]"
onClick={() => setSettingsOpen(true)}
aria-label={t('settings')}
title={t('settings')}
>
<Settings2 className="h-5 w-5" />
</Button> </Button>
) : null}
<Select value={lang} onValueChange={(v) => setLang(v as Lang)}>
<SelectTrigger className="w-[88px]"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="en">EN</SelectItem>
<SelectItem value="de">DE</SelectItem>
<SelectItem value="ru">RU</SelectItem>
</SelectContent>
</Select>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{err ? <Card className="control-shell border-destructive/60"><CardContent className="p-3 text-sm font-medium uppercase tracking-wide text-destructive">{err}</CardContent></Card> : null} {err ? <Card className="control-shell overflow-hidden border-destructive bg-card/95"><CardContent className="p-4 text-sm font-black uppercase tracking-[0.16em] text-destructive">{err}</CardContent></Card> : null}
{user ? (
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}> <Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t('settings')}</DialogTitle> <DialogTitle>{t('settings')}</DialogTitle>
<DialogDescription>{user.username}</DialogDescription> <DialogDescription>{user ? user.username : t('shellTagline')}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-4">
<div> <div className="space-y-2">
<p className="mb-1 text-xs text-muted-foreground">{t('theme')}</p> <p className="text-[10px] font-black uppercase tracking-[0.18em] text-muted-foreground">{t('language')}</p>
<Select value={lang} onValueChange={(v) => setLang(v as Lang)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="de">Deutsch</SelectItem>
<SelectItem value="ru">Русский</SelectItem>
</SelectContent>
</Select>
</div>
{user ? (
<>
<div className="space-y-2">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-muted-foreground">{t('theme')}</p>
<Select value={user.theme} onValueChange={(v) => void savePref(v as Theme, user.colorMode, user.archiveFormat)}> <Select value={user.theme} onValueChange={(v) => void savePref(v as Theme, user.colorMode, user.archiveFormat)}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{themeOptions.map((opt) => <SelectItem key={opt} value={opt}>{t(opt)}</SelectItem>)}</SelectContent> <SelectContent>{themeOptions.map((opt) => <SelectItem key={opt} value={opt}>{t(opt)}</SelectItem>)}</SelectContent>
</Select> </Select>
</div> </div>
<div> <div className="space-y-2">
<p className="mb-1 text-xs text-muted-foreground">{t('mode')}</p> <p className="text-[10px] font-black uppercase tracking-[0.18em] text-muted-foreground">{t('mode')}</p>
<Select value={user.colorMode} onValueChange={(v) => void savePref(user.theme, v as ColorMode, user.archiveFormat)}> <Select value={user.colorMode} onValueChange={(v) => void savePref(user.theme, v as ColorMode, user.archiveFormat)}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{modeOptions.map((opt) => <SelectItem key={opt} value={opt}>{opt}</SelectItem>)}</SelectContent> <SelectContent>{modeOptions.map((opt) => <SelectItem key={opt} value={opt}>{opt}</SelectItem>)}</SelectContent>
</Select> </Select>
</div> </div>
<div> <div className="space-y-2">
<p className="mb-1 text-xs text-muted-foreground">{t('archiveFormat')}</p> <p className="text-[10px] font-black uppercase tracking-[0.18em] text-muted-foreground">{t('archiveFormat')}</p>
<Select value={user.archiveFormat} onValueChange={(v) => void savePref(user.theme, user.colorMode, v as ArchiveFormat)}> <Select value={user.archiveFormat} onValueChange={(v) => void savePref(user.theme, user.colorMode, v as ArchiveFormat)}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
@@ -1261,10 +1338,10 @@ export default function App() {
</Select> </Select>
</div> </div>
{protocolInfo?.ftp || protocolInfo?.ftps ? ( {protocolInfo?.ftp || protocolInfo?.ftps ? (
<div className="space-y-2 rounded-lg border bg-muted/40 p-3"> <div className="brutal-block space-y-3 p-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{t('remoteAccess')}</p> <p className="text-[10px] font-black uppercase tracking-[0.18em] text-muted-foreground">{t('remoteAccess')}</p>
{protocolInfo.ftp ? ( {protocolInfo.ftp ? (
<div className="space-y-1 rounded-md border bg-background p-2 text-xs"> <div className="border-[3px] border-border bg-background p-3 text-xs shadow-[4px_4px_0_hsl(var(--shadow-strong))]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="font-medium">{t('ftpAccess')}</p> <p className="font-medium">{t('ftpAccess')}</p>
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => void navigator.clipboard.writeText(protocolURI('ftp', protocolInfo.ftp as ProtocolProfile))}> <Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => void navigator.clipboard.writeText(protocolURI('ftp', protocolInfo.ftp as ProtocolProfile))}>
@@ -1279,7 +1356,7 @@ export default function App() {
</div> </div>
) : null} ) : null}
{protocolInfo.ftps ? ( {protocolInfo.ftps ? (
<div className="space-y-1 rounded-md border bg-background p-2 text-xs"> <div className="border-[3px] border-border bg-background p-3 text-xs shadow-[4px_4px_0_hsl(var(--shadow-strong))]">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="font-medium">{t('ftpsAccess')}</p> <p className="font-medium">{t('ftpsAccess')}</p>
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => void navigator.clipboard.writeText(protocolURI('ftps', protocolInfo.ftps as ProtocolProfile))}> <Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => void navigator.clipboard.writeText(protocolURI('ftps', protocolInfo.ftps as ProtocolProfile))}>
@@ -1295,8 +1372,21 @@ export default function App() {
) : null} ) : null}
</div> </div>
) : null} ) : null}
<div className="flex flex-wrap items-center gap-3">
<Button <Button
size="sm"
variant="secondary"
className="h-10 w-10 shrink-0 p-0"
onClick={linkGoogleAccount}
aria-label={t('linkGoogle')}
title={t('linkGoogle')}
>
<GoogleIcon className="h-5 w-5" />
</Button>
<Button
size="sm"
variant="outline" variant="outline"
className="h-10 px-3 sm:ml-auto"
onClick={() => { onClick={() => {
setSettingsOpen(false) setSettingsOpen(false)
void api('/api/auth/logout', { method: 'POST' }).then(() => { void api('/api/auth/logout', { method: 'POST' }).then(() => {
@@ -1308,51 +1398,91 @@ export default function App() {
<LogOut className="mr-2 h-4 w-4" />{t('logout')} <LogOut className="mr-2 h-4 w-4" />{t('logout')}
</Button> </Button>
</div> </div>
</>
) : null}
</div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) : null}
{route === 'landing' ? ( {route === 'landing' ? (
<section className="grid gap-4 lg:grid-cols-[1.35fr_1fr]"> <section className="grid gap-6 xl:grid-cols-[minmax(0,1.28fr)_420px]">
<Card className="control-shell border-primary/40 bg-[linear-gradient(135deg,hsl(var(--card))_0%,hsl(var(--card)/0.8)_55%,hsl(var(--primary)/0.18)_100%)]"> <Card className="control-shell overflow-hidden bg-card/95">
<CardHeader className="space-y-4"> <CardContent className="grid gap-0 p-0 lg:grid-cols-[minmax(0,1fr)_320px]">
<Badge variant="secondary" className="w-fit border border-border bg-muted/30 uppercase tracking-[0.14em]">Control Node</Badge> <div className="space-y-10 p-7 md:p-10">
<CardTitle className="font-display max-w-2xl text-3xl leading-tight md:text-6xl">{t('heroTitle')}</CardTitle> <div className="flex flex-wrap gap-2">
<CardDescription className="max-w-xl text-base text-muted-foreground md:text-lg">{t('heroDesc')}</CardDescription> <Badge variant="secondary">{t('controlNode')}</Badge>
</CardHeader> <Badge variant="outline">{t('drive')}</Badge>
<CardContent className="space-y-6"> </div>
<div className="flex gap-2"> <div className="space-y-4">
<CardTitle className="font-display max-w-4xl text-6xl md:text-8xl">{t('heroTitle')}</CardTitle>
<CardDescription className="max-w-2xl text-lg text-foreground/80 md:text-2xl">{t('heroDesc')}</CardDescription>
</div>
<div className="flex flex-wrap gap-3">
<Button size="lg" onClick={() => navigate('drive')}>{t('openDrive')}</Button> <Button size="lg" onClick={() => navigate('drive')}>{t('openDrive')}</Button>
<Button size="lg" variant="outline" onClick={() => navigate('admin')}>{t('openAdmin')}</Button> <Button size="lg" variant="outline" onClick={() => navigate('admin')}>{t('openAdmin')}</Button>
</div> </div>
<div className="grid gap-3 sm:grid-cols-3"> <div className="grid gap-3 md:grid-cols-3">
<div className="border-2 border-border bg-background/80 p-3 text-sm"><p className="font-display text-base">Argon2id Auth</p><p className="text-xs uppercase tracking-wide text-muted-foreground">Hardened credentials and sessions</p></div> <div className="brutal-block p-4 text-sm">
<div className="border-2 border-border bg-background/80 p-3 text-sm"><p className="font-display text-base">Isolated Storage</p><p className="text-xs uppercase tracking-wide text-muted-foreground">Per-account file boundaries</p></div> <p className="font-display text-xl">Argon2id</p>
<div className="border-2 border-border bg-background/80 p-3 text-sm"><p className="font-display text-base">Share + Resume</p><p className="text-xs uppercase tracking-wide text-muted-foreground">Expiring links and byte-range downloads</p></div> <p className="mt-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">{t('authFeatureDesc')}</p>
</div>
<div className="brutal-block p-4 text-sm">
<p className="font-display text-xl">{t('drive')}</p>
<p className="mt-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">{t('storageFeatureDesc')}</p>
</div>
<div className="brutal-block p-4 text-sm">
<p className="font-display text-xl">{t('share')}</p>
<p className="mt-2 text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">{t('shareFeatureDesc')}</p>
</div>
</div>
</div>
<div className="grid gap-px border-t-[3px] border-border bg-border lg:border-l-[3px] lg:border-t-0">
<div className="bg-accent p-5 text-accent-foreground">
<p className="text-[10px] font-black uppercase tracking-[0.22em]">/drive</p>
<p className="mt-4 font-display text-4xl">{t('drive')}</p>
<p className="mt-3 text-sm font-semibold uppercase tracking-[0.14em]">{t('openDrive')}</p>
</div>
<div className="bg-primary p-5 text-primary-foreground">
<p className="text-[10px] font-black uppercase tracking-[0.22em]">/admin</p>
<p className="mt-4 font-display text-4xl">{t('admin')}</p>
<p className="mt-3 text-sm font-semibold uppercase tracking-[0.14em]">{t('openAdmin')}</p>
</div>
<div className="bg-card p-5">
<p className="text-[10px] font-black uppercase tracking-[0.22em] text-muted-foreground">{t('remoteAccess')}</p>
<p className="mt-4 font-display text-4xl">FTP</p>
<p className="mt-3 text-sm font-semibold uppercase tracking-[0.14em] text-muted-foreground">{t('remoteAccessDesc')}</p>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="control-shell bg-card/95"> <Card className="control-shell overflow-hidden bg-card/95">
<CardHeader> <CardHeader className="gap-4 border-b-[3px] border-border bg-[linear-gradient(135deg,hsl(var(--primary)/0.16),transparent_70%)]">
<CardTitle className="font-display text-3xl">{t('userLogin')}</CardTitle> <Badge className="w-fit">{t('userLogin')}</Badge>
<CardDescription className="max-w-md uppercase tracking-wide">{t('registrationDisabled')}</CardDescription> <CardTitle className="font-display text-5xl">{t('userLogin')}</CardTitle>
<CardDescription className="max-w-md uppercase tracking-[0.14em]">{t('registrationDisabled')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-5 p-6 pt-6">
<form className="space-y-3" onSubmit={loginUser}> <form className="space-y-3" onSubmit={loginUser}>
<div className="space-y-2">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-muted-foreground">{t('username')}</p>
<Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder={t('username')} required /> <Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder={t('username')} required />
</div>
<div className="space-y-2">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-muted-foreground">{t('password')}</p>
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder={t('password')} required /> <Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder={t('password')} required />
</div>
<Button className="w-full" type="submit">{t('signIn')}</Button> <Button className="w-full" type="submit">{t('signIn')}</Button>
<Button className="w-full" type="button" variant="outline" onClick={loginWithGoogle}>{t('signInGoogle')}</Button> <Button className="w-full" type="button" variant="secondary" onClick={loginWithGoogle}>{t('signInGoogle')}</Button>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
</section> </section>
) : route === 'drive' ? ( ) : route === 'drive' ? (
user ? ( user ? (
<section className="grid gap-4 lg:grid-cols-[280px_1fr]"> <section className="grid min-h-0 flex-1 gap-6 overflow-y-auto xl:grid-cols-[360px_minmax(0,1fr)] xl:overflow-hidden">
<Card className="control-shell h-fit bg-card/95"> <Card className="control-shell h-fit overflow-hidden bg-card/95 xl:h-full">
<Suspense fallback={<CardContent className="p-4 text-sm text-muted-foreground">Loading...</CardContent>}> <Suspense fallback={<CardContent className="p-5 text-sm font-semibold uppercase tracking-[0.16em] text-muted-foreground">{t('loading')}...</CardContent>}>
<TransferSection <TransferSection
username={user.username} username={user.username}
t={t} t={t}
@@ -1376,26 +1506,34 @@ export default function App() {
</Suspense> </Suspense>
</Card> </Card>
<Card className={`control-shell bg-card/95 ${dragActive ? 'ring-2 ring-primary/50' : ''}`}> <Card className={`control-shell min-h-0 overflow-hidden bg-card/95 xl:flex xl:flex-col ${dragActive ? '-translate-x-1 -translate-y-1 shadow-[16px_16px_0_hsl(var(--shadow-strong))]' : ''}`}>
<CardHeader className="space-y-3 pb-2"> <CardHeader className="gap-6 border-b-[3px] border-border bg-[linear-gradient(135deg,hsl(var(--primary)/0.1),transparent_62%)] pb-6">
<div className="flex items-center justify-between"> <div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_minmax(420px,480px)] xl:items-start">
<CardTitle className="font-display text-3xl">{t('drive')}</CardTitle> <div className="space-y-4">
<div className="flex items-center gap-2"> <div className="space-y-3">
{selectedCount > 0 ? ( <div className="flex flex-wrap gap-2">
<Badge variant="outline">{selectedCount} {t('selected')}</Badge> <Badge>{routeLabel}</Badge>
) : null} {selectedCount > 0 ? <Badge variant="secondary">{selectedCount} {t('selected')}</Badge> : null}
<Badge variant="secondary">{visibleFiles.length} {t('items')}</Badge> </div>
<CardTitle className="font-display text-5xl md:text-6xl">{t('drive')}</CardTitle>
</div> </div>
</div> </div>
<div className="space-y-3 self-start">
<div className="relative"> <div className="relative">
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-muted-foreground" /> <Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input ref={searchRef} className="h-11 rounded-xl pl-9" value={query} onChange={(e) => setQuery(e.target.value)} placeholder={t('search')} /> <Input
ref={searchRef}
className="h-14 pl-12 pr-4 text-[13px] placeholder:tracking-[0.1em]"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('search')}
/>
</div> </div>
<div className="-mx-1 flex items-center gap-1 overflow-x-auto px-1 text-xs uppercase tracking-[0.14em] text-muted-foreground whitespace-nowrap"> <div className="brutal-block flex min-h-[58px] items-center gap-1 overflow-x-auto px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground whitespace-nowrap">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className={`h-7 shrink-0 px-2 ${dropFolderPath === '/' ? 'ring-2 ring-primary/40' : ''}`} className={`h-9 shrink-0 px-3 ${dropFolderPath === '/' ? 'border-border bg-muted' : ''}`}
onClick={() => void loadFiles('/')} onClick={() => void loadFiles('/')}
onDragOver={(e) => { onDragOver={(e) => {
if (draggingPaths.length === 0) return if (draggingPaths.length === 0) return
@@ -1411,24 +1549,26 @@ export default function App() {
if (paths.length === 0) return if (paths.length === 0) return
void movePathsTo('/', paths) void movePathsTo('/', paths)
}} }}
>root</Button> >{t('root')}</Button>
{crumbs.map((crumb, idx) => { {crumbs.map((crumb, idx) => {
const p = '/' + crumbs.slice(0, idx + 1).join('/') const p = '/' + crumbs.slice(0, idx + 1).join('/')
return ( return (
<div key={p} className="flex shrink-0 items-center gap-1"> <div key={p} className="flex shrink-0 items-center gap-1">
<span className="text-muted-foreground">/</span> <span className="text-muted-foreground">/</span>
<Button size="sm" variant="ghost" className="h-7 shrink-0 px-2" onClick={() => void loadFiles(p)}> <Button size="sm" variant="ghost" className="h-9 shrink-0 px-3" onClick={() => void loadFiles(p)}>
<span className="max-w-[11rem] truncate">{crumb}</span> <span className="max-w-[11rem] truncate">{crumb}</span>
</Button> </Button>
</div> </div>
) )
})} })}
</div> </div>
<p className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">{t('shortcuts')}</p> <p className="pl-1 text-[11px] font-semibold uppercase tracking-[0.18em] leading-relaxed text-muted-foreground">{t('shortcuts')}</p>
</div>
</div>
</CardHeader> </CardHeader>
<CardContent <CardContent
className="min-h-[420px] space-y-2" className="min-h-[500px] space-y-4 p-6 pt-6 xl:min-h-0 xl:flex-1 xl:overflow-y-auto"
onMouseDown={(e) => { onMouseDown={(e) => {
if (e.button !== 0) return if (e.button !== 0) return
if (isSelectionIgnoredTarget(e.target)) return if (isSelectionIgnoredTarget(e.target)) return
@@ -1484,16 +1624,15 @@ export default function App() {
/> />
<ContextMenu> <ContextMenu>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<div className="flex min-h-full flex-col gap-2"> <div className="flex min-h-0 flex-col gap-3">
<Dialog open={shareDialog} onOpenChange={(v) => setShareDialog(v)}> <Dialog open={shareDialog} onOpenChange={(v) => setShareDialog(v)}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t('share')}</DialogTitle> <DialogTitle>{t('share')}</DialogTitle>
<DialogDescription>{filesByPath[sharePath]?.name ?? sharePath}</DialogDescription> <DialogDescription>{filesByPath[sharePath]?.name ?? sharePath}</DialogDescription>
</DialogHeader> </DialogHeader>
<div> <div className="space-y-2">
<p className="mb-1 text-xs text-muted-foreground">{t('shareFor')}</p> <p className="text-[10px] font-black uppercase tracking-[0.18em] text-muted-foreground">{t('shareFor')}</p>
<Select value={shareTTL} onValueChange={setShareTTL}> <Select value={shareTTL} onValueChange={setShareTTL}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
@@ -1529,13 +1668,13 @@ export default function App() {
<img <img
src={`/api/files/preview?path=${encodeURIComponent(previewEntry.path)}`} src={`/api/files/preview?path=${encodeURIComponent(previewEntry.path)}`}
alt={previewEntry.name} alt={previewEntry.name}
className="max-h-[70vh] w-full rounded-md object-contain" className="max-h-[70vh] w-full border-[3px] border-border object-contain"
/> />
) : null} ) : null}
{previewEntry && isVideoFile(previewEntry.name) ? ( {previewEntry && isVideoFile(previewEntry.name) ? (
<video <video
controls controls
className="max-h-[70vh] w-full rounded-md bg-black" className="max-h-[70vh] w-full border-[3px] border-border bg-black"
src={`/api/files/preview?path=${encodeURIComponent(previewEntry.path)}`} src={`/api/files/preview?path=${encodeURIComponent(previewEntry.path)}`}
/> />
) : null} ) : null}
@@ -1548,19 +1687,19 @@ export default function App() {
<DialogTitle>{t('editor')}</DialogTitle> <DialogTitle>{t('editor')}</DialogTitle>
<DialogDescription>{editorPath}</DialogDescription> <DialogDescription>{editorPath}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-3 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-muted-foreground">{t('writeMarkdown')}</p> <p className="text-[10px] font-black uppercase tracking-[0.18em] text-muted-foreground">{t('writeMarkdown')}</p>
<Textarea <Textarea
value={editorContent} value={editorContent}
onChange={(e) => setEditorContent(e.target.value)} onChange={(e) => setEditorContent(e.target.value)}
className="min-h-[50vh] w-full rounded-md border bg-background p-3 text-sm" className="min-h-[50vh] w-full"
/> />
<Button onClick={() => void saveMarkdown()} disabled={editorSaving}>{editorSaving ? '...' : t('save')}</Button> <Button onClick={() => void saveMarkdown()} disabled={editorSaving}>{editorSaving ? '...' : t('save')}</Button>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<p className="text-xs text-muted-foreground">{t('markdownPreview')}</p> <p className="text-[10px] font-black uppercase tracking-[0.18em] text-muted-foreground">{t('markdownPreview')}</p>
<div className="markdown-preview max-w-none rounded-md border bg-muted/30 p-3 text-sm"> <div className="markdown-preview brutal-block max-w-none p-4 text-sm">
{MarkdownRenderer ? ( {MarkdownRenderer ? (
<MarkdownRenderer>{editorContent}</MarkdownRenderer> <MarkdownRenderer>{editorContent}</MarkdownRenderer>
) : ( ) : (
@@ -1572,14 +1711,14 @@ export default function App() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<div className="hidden grid-cols-[1fr_110px_160px] border-2 border-border bg-muted/40 p-3 text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground lg:grid"> <div className="hidden grid-cols-[minmax(0,1fr)_140px_190px] border-[3px] border-border bg-muted/70 p-4 text-sm font-black uppercase tracking-[0.16em] text-muted-foreground lg:grid">
<button onClick={() => toggleSort('name')} className="text-left">{t('name')}</button> <button onClick={() => toggleSort('name')} className="text-left">{t('name')}</button>
<button onClick={() => toggleSort('size')} className="text-left">{t('size')}</button> <button onClick={() => toggleSort('size')} className="text-left">{t('size')}</button>
<button onClick={() => toggleSort('modTime')} className="text-left">{t('modified')}</button> <button onClick={() => toggleSort('modTime')} className="text-left">{t('modified')}</button>
</div> </div>
{visibleFiles.length === 0 ? ( {visibleFiles.length === 0 ? (
<div className="border-2 border-dashed border-border p-10 text-center text-sm uppercase tracking-wide text-muted-foreground">{t('empty')}</div> <div className="brutal-block p-10 text-center text-sm font-black uppercase tracking-[0.18em] text-muted-foreground">{t('empty')}</div>
) : ( ) : (
visibleFiles.map((f) => ( visibleFiles.map((f) => (
<ContextMenu key={f.path}> <ContextMenu key={f.path}>
@@ -1587,7 +1726,7 @@ export default function App() {
<div <div
data-file-row="true" data-file-row="true"
draggable draggable
className={`group grid gap-2 border-2 border-border bg-background/90 p-3 transition hover:bg-muted/35 lg:grid-cols-[1fr_110px_160px] lg:items-center ${selectedPaths[f.path] ? 'ring-2 ring-primary/45 bg-gradient-to-r from-primary/12 via-background to-background shadow-[0_0_24px_hsl(var(--primary)/0.22)]' : ''} ${dropFolderPath === f.path ? 'border-primary/70 bg-primary/12' : ''} ${draggingPaths.includes(f.path) ? 'opacity-70' : ''} ${paintSelect.active ? 'select-none' : ''}`} className={`group grid items-center gap-4 border-[3px] border-border bg-card p-5 shadow-[4px_4px_0_hsl(var(--shadow-strong))] transition-[transform,box-shadow,background-color,border-color,opacity] duration-150 hover:-translate-x-1 hover:-translate-y-1 hover:bg-muted hover:shadow-[8px_8px_0_hsl(var(--shadow-strong))] lg:grid-cols-[minmax(0,1fr)_140px_190px] ${selectedPaths[f.path] ? 'border-primary bg-primary/10 shadow-[8px_8px_0_hsl(var(--shadow-strong))]' : ''} ${dropFolderPath === f.path ? 'border-primary bg-accent/35' : ''} ${draggingPaths.includes(f.path) ? 'opacity-70' : ''} ${paintSelect.active ? 'select-none' : ''}`}
onMouseDown={(e) => { onMouseDown={(e) => {
if (e.button !== 0) return if (e.button !== 0) return
if (isSelectionIgnoredTarget(e.target)) return if (isSelectionIgnoredTarget(e.target)) return
@@ -1659,21 +1798,23 @@ export default function App() {
}} }}
> >
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<KindIcon file={f} /> <KindIcon file={f} />
<p className="truncate text-left text-sm font-semibold uppercase tracking-[0.08em] hover:underline">{f.name}</p> <p className="truncate text-left text-base font-black uppercase tracking-[0.12em] group-hover:underline">{f.name}</p>
</div> </div>
<div className="mt-2 flex flex-wrap items-center gap-1"> {(f.tags?.length ?? 0) > 0 ? (
<div className="mt-3 flex flex-wrap items-center gap-2">
{(f.tags ?? []).map((tag) => ( {(f.tags ?? []).map((tag) => (
<Badge key={tag} variant="secondary" className="gap-1"> <Badge key={tag} variant="outline" className="gap-1">
#{tag} #{tag}
<button onClick={() => void removeTag(f.path, tag)} className="text-muted-foreground">x</button> <button onClick={() => void removeTag(f.path, tag)} className="text-muted-foreground">x</button>
</Badge> </Badge>
))} ))}
</div> </div>
) : null}
</div> </div>
<p className="text-xs text-muted-foreground">{f.isDir ? '—' : formatSize(f.size)}</p> <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{f.isDir ? '—' : formatSize(f.size)}</p>
<p className="text-xs text-muted-foreground">{new Date(f.modTime).toLocaleString()}</p> <p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground">{new Date(f.modTime).toLocaleString()}</p>
</div> </div>
</ContextMenuTrigger> </ContextMenuTrigger>
<ContextMenuContent> <ContextMenuContent>
@@ -1717,23 +1858,30 @@ export default function App() {
) : bootstrapping ? ( ) : bootstrapping ? (
authLoadingCard authLoadingCard
) : ( ) : (
<Card className="control-shell mx-auto max-w-md bg-card/95"> <Card className="control-shell mx-auto max-w-md overflow-hidden bg-card/95">
<CardHeader> <CardHeader className="gap-4 border-b-[3px] border-border bg-[linear-gradient(135deg,hsl(var(--primary)/0.16),transparent_70%)]">
<CardTitle className="font-display text-3xl">{t('userLogin')}</CardTitle> <Badge className="w-fit">{t('userLogin')}</Badge>
<CardTitle className="font-display text-5xl">{t('userLogin')}</CardTitle>
<CardDescription>{t('registrationDisabled')}</CardDescription> <CardDescription>{t('registrationDisabled')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-5 p-6 pt-6">
<form className="space-y-3" onSubmit={loginUser}> <form className="space-y-3" onSubmit={loginUser}>
<div className="space-y-2">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-muted-foreground">{t('username')}</p>
<Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder={t('username')} required /> <Input value={username} onChange={(e) => setUsername(e.target.value)} placeholder={t('username')} required />
</div>
<div className="space-y-2">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-muted-foreground">{t('password')}</p>
<Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder={t('password')} required /> <Input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder={t('password')} required />
</div>
<Button className="w-full" type="submit">{t('signIn')}</Button> <Button className="w-full" type="submit">{t('signIn')}</Button>
<Button className="w-full" type="button" variant="outline" onClick={loginWithGoogle}>{t('signInGoogle')}</Button> <Button className="w-full" type="button" variant="secondary" onClick={loginWithGoogle}>{t('signInGoogle')}</Button>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
) )
) : admin ? ( ) : admin ? (
<Suspense fallback={<Card className="border-none bg-card/95 shadow-lg"><CardContent className="p-4 text-sm text-muted-foreground">Loading...</CardContent></Card>}> <Suspense fallback={<Card className="control-shell overflow-hidden bg-card/95"><CardContent className="p-5 text-sm font-semibold uppercase tracking-[0.16em] text-muted-foreground">{t('loading')}...</CardContent></Card>}>
<AdminPanel <AdminPanel
t={t} t={t}
admin={admin} admin={admin}
@@ -1756,23 +1904,30 @@ export default function App() {
) : bootstrapping ? ( ) : bootstrapping ? (
authLoadingCard authLoadingCard
) : ( ) : (
<Card className="control-shell mx-auto max-w-md bg-card/95"> <Card className="control-shell mx-auto max-w-md overflow-hidden bg-card/95">
<CardHeader> <CardHeader className="gap-4 border-b-[3px] border-border bg-[linear-gradient(135deg,hsl(var(--accent)/0.24),transparent_70%)]">
<CardTitle className="font-display text-3xl">{t('adminLogin')}</CardTitle> <Badge className="w-fit">{t('admin')}</Badge>
<CardDescription className="uppercase tracking-[0.12em]">{t('configuredFromEnv')}</CardDescription> <CardTitle className="font-display text-5xl">{t('adminLogin')}</CardTitle>
<CardDescription className="uppercase tracking-[0.14em]">{t('configuredFromEnv')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-5 p-6 pt-6">
<form className="space-y-3" onSubmit={loginAdmin}> <form className="space-y-3" onSubmit={loginAdmin}>
<div className="space-y-2">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-muted-foreground">{t('username')}</p>
<Input value={adminLogin} onChange={(e) => setAdminLogin(e.target.value)} placeholder={t('username')} required /> <Input value={adminLogin} onChange={(e) => setAdminLogin(e.target.value)} placeholder={t('username')} required />
</div>
<div className="space-y-2">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-muted-foreground">{t('password')}</p>
<Input type="password" value={adminPass} onChange={(e) => setAdminPass(e.target.value)} placeholder={t('password')} required /> <Input type="password" value={adminPass} onChange={(e) => setAdminPass(e.target.value)} placeholder={t('password')} required />
</div>
<Button className="w-full" type="submit"><Shield className="mr-2 h-4 w-4" />{t('signInAdmin')}</Button> <Button className="w-full" type="submit"><Shield className="mr-2 h-4 w-4" />{t('signInAdmin')}</Button>
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
)} )}
<footer className="py-2 text-center text-xs uppercase tracking-[0.2em] text-muted-foreground"> <footer className="py-3 text-center text-xs font-black uppercase tracking-[0.22em] text-muted-foreground">
<span className="inline-flex items-center gap-1"><Star className="h-3 w-3" /> FileZ Control Surface</span> <span className="brutal-chip"><Star className="h-3 w-3" /> {t('controlSurface')}</span>
</footer> </footer>
</div> </div>
</main> </main>

View File

@@ -3,12 +3,12 @@ import type { HTMLAttributes } from 'react'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
const badgeVariants = cva('inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-semibold', { const badgeVariants = cva('inline-flex items-center rounded-none border-[3px] px-2.5 py-1 text-[10px] font-black uppercase tracking-[0.16em] shadow-[3px_3px_0_hsl(var(--shadow-strong))]', {
variants: { variants: {
variant: { variant: {
default: 'border-transparent bg-primary text-primary-foreground', default: 'border-border bg-primary text-primary-foreground',
secondary: 'border-transparent bg-muted text-foreground', secondary: 'border-border bg-accent text-accent-foreground',
outline: 'text-foreground', outline: 'border-border bg-card text-foreground',
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@@ -4,20 +4,20 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
const buttonVariants = cva( const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-none border-2 border-transparent text-sm font-semibold uppercase tracking-[0.08em] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:pointer-events-none disabled:opacity-50', 'inline-flex items-center justify-center whitespace-nowrap rounded-none border-[3px] text-sm font-black uppercase tracking-[0.14em] transition-[transform,box-shadow,background-color,color,border-color] duration-150 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/25 disabled:pointer-events-none disabled:opacity-50',
{ {
variants: { variants: {
variant: { variant: {
default: 'border-primary bg-primary text-primary-foreground hover:bg-primary/85', default: 'border-border bg-primary text-primary-foreground shadow-[4px_4px_0_hsl(var(--shadow-strong))] hover:-translate-x-1 hover:-translate-y-1 hover:shadow-[8px_8px_0_hsl(var(--shadow-strong))] active:translate-x-[3px] active:translate-y-[3px] active:shadow-[0_0_0_hsl(var(--shadow-strong))]',
outline: 'border-input bg-background hover:bg-muted', outline: 'border-border bg-card text-foreground shadow-[4px_4px_0_hsl(var(--shadow-strong))] hover:-translate-x-1 hover:-translate-y-1 hover:bg-muted hover:shadow-[8px_8px_0_hsl(var(--shadow-strong))] active:translate-x-[3px] active:translate-y-[3px] active:shadow-[0_0_0_hsl(var(--shadow-strong))]',
secondary: 'border-border bg-muted text-foreground hover:bg-muted/70', secondary: 'border-border bg-accent text-accent-foreground shadow-[4px_4px_0_hsl(var(--shadow-strong))] hover:-translate-x-1 hover:-translate-y-1 hover:shadow-[8px_8px_0_hsl(var(--shadow-strong))] active:translate-x-[3px] active:translate-y-[3px] active:shadow-[0_0_0_hsl(var(--shadow-strong))]',
ghost: 'border-transparent hover:border-border hover:bg-muted/40 hover:text-foreground', ghost: 'border-transparent bg-transparent text-foreground shadow-none hover:border-border hover:bg-muted/65',
destructive: 'border-destructive bg-destructive text-destructive-foreground hover:bg-destructive/85', destructive: 'border-border bg-destructive text-destructive-foreground shadow-[4px_4px_0_hsl(var(--shadow-strong))] hover:-translate-x-1 hover:-translate-y-1 hover:shadow-[8px_8px_0_hsl(var(--shadow-strong))] active:translate-x-[3px] active:translate-y-[3px] active:shadow-[0_0_0_hsl(var(--shadow-strong))]',
}, },
size: { size: {
default: 'h-10 px-4 py-2', default: 'h-11 px-4 py-2',
sm: 'h-8 px-3 text-xs', sm: 'h-9 px-3 text-[11px]',
lg: 'h-11 px-8', lg: 'h-12 px-6 text-base',
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@@ -3,11 +3,11 @@ import * as React from 'react'
import { cn } from '../../lib/utils' import { cn } from '../../lib/utils'
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('rounded-none border-2 border-border bg-card text-card-foreground shadow-[6px_6px_0_hsl(var(--shadow-strong))]', className)} {...props} /> return <div className={cn('rounded-none border-[3px] border-border bg-card text-card-foreground shadow-[8px_8px_0_hsl(var(--shadow-strong))]', className)} {...props} />
} }
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} /> return <div className={cn('flex flex-col space-y-2 p-6', className)} {...props} />
} }
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) { export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
@@ -15,7 +15,7 @@ export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHead
} }
export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) { export function CardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
return <p className={cn('text-sm text-muted-foreground', className)} {...props} /> return <p className={cn('text-sm leading-relaxed text-muted-foreground', className)} {...props} />
} }
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) { export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {

View File

@@ -13,7 +13,7 @@ const ContextMenuContent = React.forwardRef<
<ContextMenuPrimitive.Content <ContextMenuPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md', 'z-50 min-w-[12rem] overflow-hidden rounded-none border-[3px] border-border bg-popover p-1.5 text-popover-foreground shadow-[8px_8px_0_hsl(var(--shadow-strong))]',
className, className,
)} )}
{...props} {...props}
@@ -28,7 +28,7 @@ const ContextMenuItem = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Item <ContextMenuPrimitive.Item
ref={ref} ref={ref}
className={cn('relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', className)} className={cn('relative flex cursor-default select-none items-center rounded-none border border-transparent px-2 py-2 text-sm font-semibold uppercase tracking-[0.08em] outline-none focus:border-border focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', className)}
{...props} {...props}
/> />
)) ))
@@ -38,7 +38,7 @@ const ContextMenuSeparator = React.forwardRef<
React.ElementRef<typeof ContextMenuPrimitive.Separator>, React.ElementRef<typeof ContextMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator> React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<ContextMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} /> <ContextMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-[3px] bg-border', className)} {...props} />
)) ))
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName

View File

@@ -14,7 +14,11 @@ export const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>, React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay ref={ref} className={cn('fixed inset-0 z-50 bg-black/70 backdrop-blur-[1px]', className)} {...props} /> <DialogPrimitive.Overlay
ref={ref}
className={cn('fixed inset-0 z-50 bg-black/75 backdrop-blur-[2px] [background-image:repeating-linear-gradient(45deg,transparent_0,transparent_18px,rgba(255,255,255,0.06)_18px,rgba(255,255,255,0.06)_20px)]', className)}
{...props}
/>
)) ))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
@@ -27,13 +31,13 @@ export const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-none border-2 border-border bg-card p-6 shadow-[8px_8px_0_hsl(var(--shadow-strong))]', 'fixed left-[50%] top-[50%] z-50 grid max-h-[90vh] w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-none border-[3px] border-border bg-card p-6 shadow-[12px_12px_0_hsl(var(--shadow-strong))]',
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-primary/40"> <DialogPrimitive.Close className="absolute right-4 top-4 border-[3px] border-border bg-card p-1 opacity-100 transition-colors hover:bg-muted focus:outline-none focus:ring-4 focus:ring-primary/25">
<X className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
@@ -43,14 +47,14 @@ export const DialogContent = React.forwardRef<
DialogContent.displayName = DialogPrimitive.Content.displayName DialogContent.displayName = DialogPrimitive.Content.displayName
export const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => ( export const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} /> <div className={cn('flex flex-col space-y-2 text-left', className)} {...props} />
) )
export const DialogTitle = React.forwardRef< export const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>, React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold leading-none tracking-tight', className)} {...props} /> <DialogPrimitive.Title ref={ref} className={cn('font-display text-xl font-semibold leading-none tracking-tight', className)} {...props} />
)) ))
DialogTitle.displayName = DialogPrimitive.Title.displayName DialogTitle.displayName = DialogPrimitive.Title.displayName

View File

@@ -14,7 +14,7 @@ const DropdownMenuContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
'z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md', 'z-50 min-w-[12rem] overflow-hidden rounded-none border-[3px] border-border bg-popover p-1.5 text-popover-foreground shadow-[8px_8px_0_hsl(var(--shadow-strong))]',
className, className,
)} )}
{...props} {...props}
@@ -29,7 +29,7 @@ const DropdownMenuItem = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn('relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', className)} className={cn('relative flex cursor-default select-none items-center rounded-none border border-transparent px-2 py-2 text-sm font-semibold uppercase tracking-[0.08em] outline-none focus:border-border focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50', className)}
{...props} {...props}
/> />
)) ))
@@ -39,7 +39,7 @@ const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>, React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator> React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-px bg-border', className)} {...props} /> <DropdownMenuPrimitive.Separator ref={ref} className={cn('-mx-1 my-1 h-[3px] bg-border', className)} {...props} />
)) ))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName

View File

@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
<input <input
type={type} type={type}
className={cn( className={cn(
'flex h-10 w-full rounded-none border-2 border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-50', 'flex h-12 w-full rounded-none border-[3px] border-input bg-background/95 px-3 py-2 text-sm shadow-[4px_4px_0_hsl(var(--shadow-strong))] ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/25 disabled:cursor-not-allowed disabled:opacity-50',
className, className,
)} )}
ref={ref} ref={ref}

View File

@@ -14,7 +14,7 @@ export const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
'flex h-10 w-full items-center justify-between rounded-none border-2 border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-50', 'flex h-12 w-full items-center justify-between rounded-none border-[3px] border-input bg-background/95 px-3 py-2 text-sm font-semibold uppercase tracking-[0.08em] shadow-[4px_4px_0_hsl(var(--shadow-strong))] ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-4 focus:ring-primary/25 disabled:cursor-not-allowed disabled:opacity-50',
className, className,
)} )}
{...props} {...props}
@@ -34,7 +34,7 @@ export const SelectContent = React.forwardRef<
<SelectPrimitive.Portal> <SelectPrimitive.Portal>
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn('relative z-50 min-w-[8rem] overflow-hidden rounded-none border-2 border-border bg-card text-card-foreground shadow-[4px_4px_0_hsl(var(--shadow-strong))]', className)} className={cn('relative z-50 min-w-[8rem] overflow-hidden rounded-none border-[3px] border-border bg-card text-card-foreground shadow-[8px_8px_0_hsl(var(--shadow-strong))]', className)}
{...props} {...props}
> >
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport> <SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
@@ -49,7 +49,7 @@ export const SelectItem = React.forwardRef<
>(({ className, children, ...props }, ref) => ( >(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn('relative flex w-full cursor-default select-none items-center rounded-none py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-muted', className)} className={cn('relative flex w-full cursor-default select-none items-center rounded-none border border-transparent py-2 pl-8 pr-2 text-sm font-semibold uppercase tracking-[0.08em] outline-none focus:border-border focus:bg-muted', className)}
{...props} {...props}
> >
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">

View File

@@ -12,7 +12,7 @@ export const TabsList = React.forwardRef<
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn(
'inline-flex h-10 items-center justify-center rounded-none border border-[#415273] bg-[#253149] p-1 text-[#95a4bf] shadow-[inset_0_0_0_1px_rgba(138,164,210,0.08)]', 'inline-flex items-center justify-center rounded-none border-[3px] border-border bg-card p-1 text-foreground shadow-[6px_6px_0_hsl(var(--shadow-strong))]',
className, className,
)} )}
{...props} {...props}
@@ -27,7 +27,7 @@ export const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-none border border-transparent px-3 py-1.5 text-sm font-semibold uppercase tracking-[0.08em] text-[#99a8c4] transition-colors focus:outline-none focus:ring-2 focus:ring-[#6a84b5]/60 hover:bg-[#2a3750] hover:text-[#d2deef] data-[state=active]:border-[#566a8e] data-[state=active]:bg-[#303e58] data-[state=active]:text-[#e6eefb]', 'inline-flex min-w-[96px] items-center justify-center whitespace-nowrap rounded-none border-[3px] border-transparent px-3 py-2 text-sm font-black uppercase tracking-[0.14em] text-muted-foreground transition-colors focus:outline-none focus:ring-4 focus:ring-primary/25 hover:border-border hover:bg-muted hover:text-foreground data-[state=active]:border-border data-[state=active]:bg-primary data-[state=active]:text-primary-foreground',
className, className,
)} )}
{...props} {...props}

View File

@@ -8,7 +8,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(({ classNa
return ( return (
<textarea <textarea
className={cn( className={cn(
'flex min-h-[80px] w-full rounded-none border-2 border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 disabled:cursor-not-allowed disabled:opacity-50', 'flex min-h-[80px] w-full rounded-none border-[3px] border-input bg-background/95 px-3 py-2 text-sm shadow-[4px_4px_0_hsl(var(--shadow-strong))] ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/25 disabled:cursor-not-allowed disabled:opacity-50',
className, className,
)} )}
ref={ref} ref={ref}

View File

@@ -6,134 +6,199 @@
:root { :root {
--radius: 0rem; --radius: 0rem;
--background: 220 20% 9%; --background: 42 37% 94%;
--foreground: 200 20% 92%; --foreground: 220 17% 10%;
--card: 222 18% 12%; --card: 0 0% 100%;
--card-foreground: 200 20% 92%; --card-foreground: 220 17% 10%;
--popover: var(--card); --popover: var(--card);
--popover-foreground: var(--card-foreground); --popover-foreground: var(--card-foreground);
--border: 213 14% 33%; --border: 220 17% 10%;
--input: 213 14% 33%; --input: 220 17% 10%;
--primary: 355 96% 59%; --primary: 5 88% 54%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--muted: 219 16% 16%; --muted: 48 40% 88%;
--muted-foreground: 214 15% 70%; --muted-foreground: 220 10% 28%;
--accent: var(--muted); --accent: 54 100% 66%;
--accent-foreground: var(--foreground); --accent-foreground: 220 17% 10%;
--destructive: 354 91% 52%; --destructive: 356 84% 48%;
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 100%;
--shadow-strong: 220 30% 5%; --shadow-strong: 220 17% 10%;
--surface-pop: 190 92% 46%;
--paper: 38 50% 90%;
} }
.dark { .dark {
--background: 222 20% 8%; --background: 220 14% 8%;
--foreground: 210 24% 92%; --foreground: 48 45% 94%;
--card: 220 16% 11%; --card: 220 14% 11%;
--card-foreground: 210 24% 92%; --card-foreground: 48 45% 94%;
--border: 213 14% 30%; --border: 48 45% 94%;
--input: 213 14% 30%; --input: 48 45% 94%;
--primary: 355 96% 59%; --primary: 14 97% 63%;
--primary-foreground: 0 0% 100%; --primary-foreground: 220 14% 8%;
--muted: 219 16% 14%; --muted: 220 12% 18%;
--muted-foreground: 214 14% 68%; --muted-foreground: 45 15% 73%;
--destructive: 354 91% 52%; --accent: 54 100% 67%;
--destructive-foreground: 0 0% 100%; --accent-foreground: 220 14% 8%;
--shadow-strong: 220 30% 4%; --destructive: 356 92% 60%;
--destructive-foreground: 220 14% 8%;
--shadow-strong: 0 0% 0%;
--surface-pop: 187 92% 55%;
--paper: 220 12% 16%;
} }
html[data-theme='dracula'] { html[data-theme='dracula'] {
--background: 231 15% 14%; --background: 225 13% 9%;
--foreground: 60 30% 96%; --foreground: 50 45% 95%;
--card: 232 14% 18%; --card: 225 13% 12%;
--card-foreground: 60 30% 96%; --card-foreground: 50 45% 95%;
--border: 232 12% 28%; --border: 50 45% 95%;
--input: 232 12% 28%; --input: 50 45% 95%;
--primary: 326 100% 74%; --primary: 326 100% 74%;
--primary-foreground: 231 15% 14%; --primary-foreground: 225 13% 9%;
--muted: 232 13% 22%; --muted: 225 13% 18%;
--muted-foreground: 225 18% 74%; --muted-foreground: 236 12% 78%;
--accent: 53 100% 65%;
--accent-foreground: 225 13% 9%;
--surface-pop: 191 100% 54%;
--paper: 225 12% 16%;
} }
html[data-theme='nord'] { html[data-theme='nord'] {
--background: 220 16% 17%; --background: 210 29% 94%;
--foreground: 214 32% 91%; --foreground: 215 21% 15%;
--card: 221 16% 21%; --card: 0 0% 100%;
--card-foreground: 214 32% 91%; --card-foreground: 215 21% 15%;
--border: 220 12% 31%; --border: 215 21% 15%;
--input: 220 12% 31%; --input: 215 21% 15%;
--primary: 193 43% 67%; --primary: 198 80% 42%;
--primary-foreground: 220 16% 17%; --primary-foreground: 0 0% 100%;
--muted: 220 15% 24%; --muted: 210 29% 86%;
--muted-foreground: 214 17% 71%; --muted-foreground: 215 17% 35%;
--accent: 44 100% 67%;
--accent-foreground: 215 21% 15%;
--surface-pop: 198 88% 45%;
--paper: 201 33% 88%;
} }
html[data-theme='monokai'] { html[data-theme='monokai'] {
--background: 70 12% 14%; --background: 42 17% 11%;
--foreground: 60 18% 91%; --foreground: 58 43% 93%;
--card: 72 11% 18%; --card: 42 16% 15%;
--card-foreground: 60 18% 91%; --card-foreground: 58 43% 93%;
--border: 74 10% 29%; --border: 58 43% 93%;
--input: 74 10% 29%; --input: 58 43% 93%;
--primary: 54 93% 68%; --primary: 26 96% 60%;
--primary-foreground: 70 12% 14%; --primary-foreground: 42 17% 11%;
--muted: 74 11% 22%; --muted: 42 14% 22%;
--muted-foreground: 58 11% 73%; --muted-foreground: 58 14% 74%;
--accent: 67 100% 58%;
--accent-foreground: 42 17% 11%;
--surface-pop: 143 70% 54%;
--paper: 42 14% 20%;
} }
html[data-theme='solarized'] { html[data-theme='solarized'] {
--background: 193 76% 15%; --background: 43 38% 92%;
--foreground: 44 88% 85%; --foreground: 194 74% 19%;
--card: 193 70% 19%; --card: 48 63% 98%;
--card-foreground: 44 88% 85%; --card-foreground: 194 74% 19%;
--border: 192 38% 31%; --border: 194 74% 19%;
--input: 192 38% 31%; --input: 194 74% 19%;
--primary: 18 80% 55%; --primary: 18 80% 49%;
--primary-foreground: 193 76% 15%; --primary-foreground: 48 63% 98%;
--muted: 193 58% 23%; --muted: 43 31% 82%;
--muted-foreground: 44 39% 72%; --muted-foreground: 195 26% 30%;
--accent: 45 94% 58%;
--accent-foreground: 194 74% 19%;
--surface-pop: 189 72% 38%;
--paper: 43 46% 86%;
} }
html[data-theme='github'] { html[data-theme='github'] {
--background: 0 0% 99%; --background: 0 0% 98%;
--foreground: 220 14% 18%; --foreground: 220 14% 10%;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 220 14% 18%; --card-foreground: 220 14% 10%;
--border: 210 18% 87%; --border: 220 14% 10%;
--input: 210 18% 87%; --input: 220 14% 10%;
--primary: 212 84% 44%; --primary: 212 84% 44%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--muted: 210 30% 96%; --muted: 210 30% 92%;
--muted-foreground: 214 10% 43%; --muted-foreground: 214 10% 33%;
--accent: 48 100% 63%;
--accent-foreground: 220 14% 10%;
--surface-pop: 184 88% 41%;
--paper: 41 52% 91%;
} }
html[data-theme='github'].dark { html[data-theme='github'].dark {
--background: 222 20% 10%; --background: 220 18% 9%;
--foreground: 210 17% 93%; --foreground: 214 28% 94%;
--card: 222 18% 14%; --card: 220 18% 13%;
--card-foreground: 210 17% 93%; --card-foreground: 214 28% 94%;
--border: 218 18% 27%; --border: 214 28% 94%;
--input: 218 18% 27%; --input: 214 28% 94%;
--primary: 212 88% 66%; --primary: 212 88% 66%;
--primary-foreground: 222 20% 10%; --primary-foreground: 220 18% 9%;
--muted: 221 18% 18%; --muted: 220 17% 18%;
--muted-foreground: 214 13% 71%; --muted-foreground: 214 13% 74%;
--accent: 48 100% 63%;
--accent-foreground: 220 18% 9%;
--surface-pop: 184 88% 51%;
--paper: 220 14% 17%;
}
html[data-theme='bureau'] {
--background: 36 30% 92%;
--foreground: 220 18% 9%;
--card: 38 35% 97%;
--card-foreground: 220 18% 9%;
--border: 220 18% 9%;
--input: 220 18% 9%;
--primary: 6 78% 54%;
--primary-foreground: 0 0% 100%;
--muted: 38 24% 84%;
--muted-foreground: 220 10% 28%;
--accent: 52 98% 63%;
--accent-foreground: 220 18% 9%;
--surface-pop: 186 68% 43%;
--paper: 38 28% 88%;
}
html[data-theme='bureau'].dark {
--background: 220 15% 8%;
--foreground: 42 35% 93%;
--card: 220 15% 10%;
--card-foreground: 42 35% 93%;
--border: 42 35% 93%;
--input: 42 35% 93%;
--primary: 8 92% 62%;
--primary-foreground: 220 15% 8%;
--muted: 220 12% 17%;
--muted-foreground: 42 15% 76%;
--accent: 51 96% 66%;
--accent-foreground: 220 15% 8%;
--surface-pop: 185 70% 50%;
--paper: 220 12% 14%;
} }
* { * {
@apply border-border; @apply border-border;
box-sizing: border-box;
} }
body { body {
@apply bg-background text-foreground antialiased; @apply bg-background text-foreground antialiased;
font-family: 'Space Grotesk', 'IBM Plex Sans Condensed', 'Sora', sans-serif; min-height: 100vh;
font-family: 'IBM Plex Sans Condensed', 'Arial Narrow', 'Helvetica Neue Condensed', 'Segoe UI', sans-serif;
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em; letter-spacing: 0.01em;
background-image: background-image:
linear-gradient(180deg, hsl(var(--background)) 0%, hsl(222 26% 7%) 100%), linear-gradient(180deg, hsl(var(--background)) 0%, hsl(var(--paper)) 100%),
linear-gradient(hsla(0 0% 100% / 0.045) 1px, transparent 1px), radial-gradient(circle at 78% 14%, hsl(var(--primary) / 0.16), transparent 24%),
linear-gradient(90deg, hsla(0 0% 100% / 0.045) 1px, transparent 1px), radial-gradient(circle at 18% 76%, hsl(var(--surface-pop) / 0.12), transparent 20%),
radial-gradient(1200px 650px at 74% -8%, hsl(var(--primary) / 0.16), transparent 56%), radial-gradient(circle at 48% 48%, hsl(var(--accent) / 0.06), transparent 28%);
radial-gradient(980px 560px at -8% 102%, hsl(196 100% 58% / 0.11), transparent 62%);
background-size: auto, 42px 42px, 42px 42px, auto, auto;
background-attachment: fixed; background-attachment: fixed;
} }
@@ -145,26 +210,96 @@
h2, h2,
h3, h3,
.font-display { .font-display {
font-family: 'Archivo Black', 'Anton', 'Space Grotesk', sans-serif; font-family: 'Archivo Black', 'Arial Black', 'Impact', sans-serif;
letter-spacing: 0.02em; letter-spacing: 0.045em;
text-transform: uppercase; text-transform: uppercase;
line-height: 0.94;
}
button,
input,
textarea,
select {
font: inherit;
}
::selection {
background: hsl(var(--primary));
color: hsl(var(--primary-foreground));
} }
.control-shell { .control-shell {
position: relative; position: relative;
overflow: hidden; overflow: hidden;
border: 2px solid hsl(var(--border)); border: 3px solid hsl(var(--border));
background: linear-gradient(165deg, hsl(var(--card) / 0.96), hsl(var(--card) / 0.82)); background:
linear-gradient(180deg, hsl(var(--card)) 0%, hsl(var(--card) / 0.98) 100%),
radial-gradient(circle at top right, hsl(var(--primary) / 0.08), transparent 38%);
box-shadow: 8px 8px 0 hsl(var(--shadow-strong)); box-shadow: 8px 8px 0 hsl(var(--shadow-strong));
} }
.control-shell::after { .control-shell > * {
content: ''; position: relative;
position: absolute; z-index: 1;
inset: 0; }
pointer-events: none;
background: linear-gradient(120deg, transparent 20%, hsl(var(--primary) / 0.07) 70%, transparent 100%); .control-shell::before {
mix-blend-mode: screen; content: none;
}
.brutal-block {
position: relative;
border: 3px solid hsl(var(--border));
background:
linear-gradient(180deg, hsl(var(--background) / 0.9), hsl(var(--muted) / 0.74)),
radial-gradient(circle at top right, hsl(var(--primary) / 0.05), transparent 42%);
box-shadow: 5px 5px 0 hsl(var(--shadow-strong));
}
.brutal-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border: 3px solid hsl(var(--border));
background: hsl(var(--card));
padding: 0.45rem 0.8rem;
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.18em;
text-transform: uppercase;
box-shadow: 4px 4px 0 hsl(var(--shadow-strong));
}
.brutal-grid {
background-image:
radial-gradient(circle at top right, hsl(var(--primary) / 0.12), transparent 34%),
radial-gradient(circle at bottom left, hsl(var(--surface-pop) / 0.08), transparent 24%);
}
.brutal-rule {
height: 3px;
width: 100%;
background: hsl(var(--border));
}
input::placeholder,
textarea::placeholder {
letter-spacing: 0.14em;
text-transform: uppercase;
}
::-webkit-scrollbar {
width: 12px;
height: 12px;
}
::-webkit-scrollbar-track {
background: hsl(var(--muted));
}
::-webkit-scrollbar-thumb {
border: 3px solid hsl(var(--muted));
background: hsl(var(--foreground));
} }
.markdown-preview { .markdown-preview {
@@ -222,7 +357,8 @@
} }
.markdown-preview code { .markdown-preview code {
border-radius: 0.375rem; border-radius: 0;
border: 2px solid hsl(var(--border));
background: hsl(var(--muted)); background: hsl(var(--muted));
padding: 0.1rem 0.35rem; padding: 0.1rem 0.35rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
@@ -236,7 +372,7 @@
} }
.markdown-preview blockquote { .markdown-preview blockquote {
border-left: 3px solid hsl(var(--border)); border-left: 4px solid hsl(var(--border));
padding-left: 0.75rem; padding-left: 0.75rem;
color: hsl(var(--muted-foreground)); color: hsl(var(--muted-foreground));
} }

View File

@@ -1,4 +1,5 @@
import { Button } from '../components/ui/button' import { Button } from '../components/ui/button'
import { Badge } from '../components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card'
import { Input } from '../components/ui/input' import { Input } from '../components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../components/ui/select'
@@ -47,54 +48,83 @@ export default function AdminPanel(props: Props) {
} = props } = props
return ( return (
<Card className="control-shell bg-card/95"> <Card className="control-shell overflow-hidden bg-card/95">
<CardHeader> <CardHeader className="gap-5 border-b-[3px] border-border bg-[linear-gradient(135deg,hsl(var(--accent)/0.18),transparent_70%)] pb-6">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<Badge>{t('admin')}</Badge>
</div>
<div> <div>
<CardTitle className="font-display text-3xl">{t('admin')}</CardTitle> <CardTitle className="font-display text-5xl md:text-6xl">{t('admin')}</CardTitle>
<CardDescription className="uppercase tracking-[0.14em]">{admin}</CardDescription> <CardDescription className="mt-2 uppercase tracking-[0.14em]">{admin}</CardDescription>
</div>
</div> </div>
<Button variant="outline" onClick={onLogout}> <Button variant="outline" onClick={onLogout}>
<LogOut className="mr-2 h-4 w-4" /> {t('logout')} <LogOut className="mr-2 h-4 w-4" /> {t('logout')}
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="grid gap-4 lg:grid-cols-2"> <CardContent className="grid gap-6 p-6 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<Card className="border-2 border-border bg-background/70 shadow-[5px_5px_0_hsl(var(--shadow-strong))]"> <div className="brutal-block p-6">
<CardHeader><CardTitle className="font-display text-2xl">{t('createUser')}</CardTitle></CardHeader> <div className="mb-5 space-y-2">
<CardContent> <p className="text-[10px] font-black uppercase tracking-[0.22em] text-muted-foreground">{t('createUser')}</p>
<CardTitle className="font-display text-3xl">{t('createUser')}</CardTitle>
<CardDescription>{t('configuredFromEnv')}</CardDescription>
</div>
<form className="space-y-3" onSubmit={onCreateUser}> <form className="space-y-3" onSubmit={onCreateUser}>
<div className="space-y-2">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-muted-foreground">{t('username')}</p>
<Input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder={t('username')} required /> <Input value={newUsername} onChange={(e) => setNewUsername(e.target.value)} placeholder={t('username')} required />
</div>
<div className="space-y-2">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-muted-foreground">{t('password')}</p>
<Input type="password" minLength={10} value={newPass} onChange={(e) => setNewPass(e.target.value)} placeholder={t('password')} required /> <Input type="password" minLength={10} value={newPass} onChange={(e) => setNewPass(e.target.value)} placeholder={t('password')} required />
<div className="grid grid-cols-2 gap-2"> </div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-muted-foreground">{t('theme')}</p>
<Select value={newTheme} onValueChange={setNewTheme}> <Select value={newTheme} onValueChange={setNewTheme}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{themeOptions.map((opt) => <SelectItem key={opt} value={opt}>{t(opt)}</SelectItem>)}</SelectContent> <SelectContent>{themeOptions.map((opt) => <SelectItem key={opt} value={opt}>{t(opt)}</SelectItem>)}</SelectContent>
</Select> </Select>
</div>
<div className="space-y-2">
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-muted-foreground">{t('mode')}</p>
<Select value={newMode} onValueChange={setNewMode}> <Select value={newMode} onValueChange={setNewMode}>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{modeOptions.map((opt) => <SelectItem key={opt} value={opt}>{opt}</SelectItem>)}</SelectContent> <SelectContent>{modeOptions.map((opt) => <SelectItem key={opt} value={opt}>{opt}</SelectItem>)}</SelectContent>
</Select> </Select>
</div> </div>
</div>
<Button className="w-full" type="submit">{t('createUser')}</Button> <Button className="w-full" type="submit">{t('createUser')}</Button>
</form> </form>
</CardContent> </div>
</Card>
<Card className="border-2 border-border bg-background/70 shadow-[5px_5px_0_hsl(var(--shadow-strong))]"> <div className="brutal-block p-6">
<CardHeader><CardTitle className="font-display text-2xl">{t('users')}</CardTitle></CardHeader> <div className="mb-6 flex flex-wrap items-end justify-between gap-3">
<CardContent className="space-y-2"> <div>
<p className="text-[10px] font-black uppercase tracking-[0.22em] text-muted-foreground">{t('users')}</p>
<CardTitle className="font-display text-3xl">{t('users')}</CardTitle>
</div>
<Badge variant="secondary">{users.length} {t('users')}</Badge>
</div>
<div className="space-y-3">
{users.map((u) => ( {users.map((u) => (
<div key={u.id} className="flex items-center justify-between border-2 border-border bg-background/85 p-2 text-sm"> <div key={u.id} className="brutal-block flex flex-col gap-3 p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0"> <div className="min-w-0 space-y-2">
<p className="truncate font-semibold uppercase tracking-[0.1em]">{u.username}</p> <p className="truncate font-display text-xl">{u.username}</p>
<p className="text-[11px] uppercase tracking-[0.12em] text-muted-foreground">#{u.id} · {u.theme}/{u.colorMode}</p> <div className="flex flex-wrap gap-2 text-[10px] font-black uppercase tracking-[0.16em]">
<span className="inline-flex items-center border-[3px] border-border bg-card px-2 py-1 shadow-[3px_3px_0_hsl(var(--shadow-strong))]">#{u.id}</span>
<span className="inline-flex items-center border-[3px] border-border bg-accent px-2 py-1 text-accent-foreground shadow-[3px_3px_0_hsl(var(--shadow-strong))]">{u.theme}</span>
<span className="inline-flex items-center border-[3px] border-border bg-muted px-2 py-1 shadow-[3px_3px_0_hsl(var(--shadow-strong))]">{u.colorMode}</span>
</div>
</div> </div>
<Button size="sm" variant="destructive" onClick={() => onDeleteUser(u.id)}>{t('delete')}</Button> <Button size="sm" variant="destructive" onClick={() => onDeleteUser(u.id)}>{t('delete')}</Button>
</div> </div>
))} ))}
</CardContent> </div>
</Card> </div>
</CardContent> </CardContent>
</Card> </Card>
) )

View File

@@ -2,6 +2,7 @@ import { Button } from '../components/ui/button'
import { CardContent } from '../components/ui/card' import { CardContent } from '../components/ui/card'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '../components/ui/dialog' import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '../components/ui/dialog'
import { Input } from '../components/ui/input' import { Input } from '../components/ui/input'
import { cn } from '../lib/utils'
type DriveView = 'all' | 'folders' | 'documents' | 'media' | 'archives' | 'tagged' | 'recent' type DriveView = 'all' | 'folders' | 'documents' | 'media' | 'archives' | 'tagged' | 'recent'
@@ -48,18 +49,37 @@ export default function TransferSection(props: Props) {
onDownloadSelected, onDownloadSelected,
} = props } = props
const pathLabel = path === '/' ? t('root') : path
const viewButton = (active: boolean) =>
cn(
'border-[3px] px-4 py-3 text-left text-xs font-black uppercase tracking-[0.14em] transition-[transform,box-shadow,background-color] duration-150',
active
? 'border-border bg-primary text-primary-foreground shadow-[4px_4px_0_hsl(var(--shadow-strong))]'
: 'border-border bg-card text-foreground shadow-[4px_4px_0_hsl(var(--shadow-strong))] hover:-translate-x-1 hover:-translate-y-1 hover:bg-muted hover:shadow-[8px_8px_0_hsl(var(--shadow-strong))]',
)
return ( return (
<CardContent className="space-y-3 p-4 md:p-5"> <CardContent className="space-y-5 p-5 md:p-6">
<div className="brutal-block space-y-4 p-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div> <div>
<p className="font-display text-xl font-semibold">{username}</p> <p className="text-[10px] font-black uppercase tracking-[0.22em] text-muted-foreground">{t('accountSubtitle')}</p>
<p className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">{t('accountSubtitle')}</p> <p className="font-display text-4xl">{username}</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<span className="brutal-chip">{pathLabel}</span>
{selectedCount > 0 ? <span className="brutal-chip">{selectedCount} {t('selected')}</span> : null}
</div>
</div> </div>
<div className="grid gap-2">
<Button className="w-full justify-start" onClick={onUploadFile}>{t('upload')}</Button> <Button className="w-full justify-start" onClick={onUploadFile}>{t('upload')}</Button>
<Button className="w-full justify-start" variant="outline" onClick={onUploadFolder}>{t('uploadFolder')}</Button> <Button className="w-full justify-start" variant="secondary" onClick={onUploadFolder}>{t('uploadFolder')}</Button>
<Button className="w-full justify-start" variant="outline" disabled={selectedCount === 0} onClick={onDownloadSelected}> <Button className="w-full justify-start" variant="outline" disabled={selectedCount === 0} onClick={onDownloadSelected}>
{t('download')} {selectedCount > 0 ? `(${selectedCount})` : ''} {t('download')} {selectedCount > 0 ? `(${selectedCount})` : ''}
</Button> </Button>
</div>
<Dialog open={folderDialog} onOpenChange={setFolderDialog}> <Dialog open={folderDialog} onOpenChange={setFolderDialog}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="w-full justify-start" variant="outline">{t('newFolder')}</Button> <Button className="w-full justify-start" variant="outline">{t('newFolder')}</Button>
@@ -73,24 +93,28 @@ export default function TransferSection(props: Props) {
<Button onClick={onCreateFolder}>{t('newFolder')}</Button> <Button onClick={onCreateFolder}>{t('newFolder')}</Button>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<div className="grid gap-1 border-2 border-border bg-muted/50 p-2 text-xs uppercase tracking-[0.14em]">
<button type="button" className={`border px-2 py-1 text-left ${view === 'all' ? 'border-border bg-background font-semibold' : 'border-transparent hover:border-border hover:bg-background/70'}`} onClick={() => setView('all')}>{t('allFiles')} ({filesCount})</button> <div className="brutal-block space-y-3 p-4">
<button type="button" className={`border px-2 py-1 text-left ${view === 'folders' ? 'border-border bg-background font-semibold' : 'border-transparent hover:border-border hover:bg-background/70'}`} onClick={() => setView('folders')}>{t('folders')}</button> <p className="text-[10px] font-black uppercase tracking-[0.22em] text-muted-foreground">{t('allFiles')}</p>
<button type="button" className={`border px-2 py-1 text-left ${view === 'documents' ? 'border-border bg-background font-semibold' : 'border-transparent hover:border-border hover:bg-background/70'}`} onClick={() => setView('documents')}>{t('documents')}</button> <div className="grid gap-2">
<button type="button" className={`border px-2 py-1 text-left ${view === 'media' ? 'border-border bg-background font-semibold' : 'border-transparent hover:border-border hover:bg-background/70'}`} onClick={() => setView('media')}>{t('media')}</button> <button type="button" className={viewButton(view === 'all')} onClick={() => setView('all')}>{t('allFiles')} ({filesCount})</button>
<button type="button" className={`border px-2 py-1 text-left ${view === 'archives' ? 'border-border bg-background font-semibold' : 'border-transparent hover:border-border hover:bg-background/70'}`} onClick={() => setView('archives')}>{t('archives')}</button> <button type="button" className={viewButton(view === 'folders')} onClick={() => setView('folders')}>{t('folders')}</button>
<button type="button" className={`border px-2 py-1 text-left ${view === 'tagged' ? 'border-border bg-background font-semibold' : 'border-transparent hover:border-border hover:bg-background/70'}`} onClick={() => setView('tagged')}>{t('tagged')}</button> <button type="button" className={viewButton(view === 'documents')} onClick={() => setView('documents')}>{t('documents')}</button>
<button type="button" className={`border px-2 py-1 text-left ${view === 'recent' ? 'border-border bg-background font-semibold' : 'border-transparent hover:border-border hover:bg-background/70'}`} onClick={() => setView('recent')}>{t('recent')}</button> <button type="button" className={viewButton(view === 'media')} onClick={() => setView('media')}>{t('media')}</button>
<button type="button" className={viewButton(view === 'archives')} onClick={() => setView('archives')}>{t('archives')}</button>
<button type="button" className={viewButton(view === 'tagged')} onClick={() => setView('tagged')}>{t('tagged')}</button>
<button type="button" className={viewButton(view === 'recent')} onClick={() => setView('recent')}>{t('recent')}</button>
</div>
</div> </div>
<div className="space-y-2 border-2 border-border bg-muted/50 p-2 text-xs uppercase tracking-[0.12em]"> <div className="brutal-block space-y-3 p-4">
<p className="font-semibold text-muted-foreground">{t('tags')}</p> <p className="text-[10px] font-black uppercase tracking-[0.22em] text-muted-foreground">{t('tags')}</p>
<button type="button" className={`block w-full border px-2 py-1 text-left ${!activeTag ? 'border-border bg-background font-semibold' : 'border-transparent hover:border-border hover:bg-background/70'}`} onClick={() => setActiveTag('')}># all</button> <button type="button" className={viewButton(!activeTag)} onClick={() => setActiveTag('')}># {t('all')}</button>
{Object.entries(tagCounts) {Object.entries(tagCounts)
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.slice(0, 8) .slice(0, 8)
.map(([tag, count]) => ( .map(([tag, count]) => (
<button type="button" key={tag} className={`block w-full border px-2 py-1 text-left ${activeTag === tag ? 'border-border bg-background font-semibold' : 'border-transparent hover:border-border hover:bg-background/70'}`} onClick={() => setActiveTag(tag)}>#{tag} ({count})</button> <button type="button" key={tag} className={viewButton(activeTag === tag)} onClick={() => setActiveTag(tag)}>#{tag} ({count})</button>
))} ))}
</div> </div>
</CardContent> </CardContent>