Autocommit test+ui polish
This commit is contained in:
9
Makefile
9
Makefile
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
<div className="space-y-4">
|
||||||
<CardTitle className="font-display text-3xl">{t('admin')}</CardTitle>
|
<div className="flex flex-wrap gap-2">
|
||||||
<CardDescription className="uppercase tracking-[0.14em]">{admin}</CardDescription>
|
<Badge>{t('admin')}</Badge>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="font-display text-5xl md:text-6xl">{t('admin')}</CardTitle>
|
||||||
|
<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>
|
||||||
<form className="space-y-3" onSubmit={onCreateUser}>
|
<CardTitle className="font-display text-3xl">{t('createUser')}</CardTitle>
|
||||||
|
<CardDescription>{t('configuredFromEnv')}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
<Button className="w-full" type="submit">{t('createUser')}</Button>
|
</div>
|
||||||
</form>
|
<Button className="w-full" type="submit">{t('createUser')}</Button>
|
||||||
</CardContent>
|
</form>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
<div className="brutal-block space-y-4 p-5">
|
||||||
<p className="font-display text-xl font-semibold">{username}</p>
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<p className="text-[11px] uppercase tracking-[0.16em] text-muted-foreground">{t('accountSubtitle')}</p>
|
<div>
|
||||||
|
<p className="text-[10px] font-black uppercase tracking-[0.22em] 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>
|
||||||
|
|
||||||
<Button className="w-full justify-start" onClick={onUploadFile}>{t('upload')}</Button>
|
<div className="grid gap-2">
|
||||||
<Button className="w-full justify-start" variant="outline" onClick={onUploadFolder}>{t('uploadFolder')}</Button>
|
<Button className="w-full justify-start" onClick={onUploadFile}>{t('upload')}</Button>
|
||||||
<Button className="w-full justify-start" variant="outline" disabled={selectedCount === 0} onClick={onDownloadSelected}>
|
<Button className="w-full justify-start" variant="secondary" onClick={onUploadFolder}>{t('uploadFolder')}</Button>
|
||||||
{t('download')} {selectedCount > 0 ? `(${selectedCount})` : ''}
|
<Button className="w-full justify-start" variant="outline" disabled={selectedCount === 0} onClick={onDownloadSelected}>
|
||||||
</Button>
|
{t('download')} {selectedCount > 0 ? `(${selectedCount})` : ''}
|
||||||
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user