feat: overhaul drive UI and previews

This commit is contained in:
mixa
2026-03-10 17:58:02 +03:00
parent dd89a5cf2d
commit c3a758e8d6
9 changed files with 2959 additions and 394 deletions

View File

@@ -122,6 +122,7 @@ Important values:
- `CORS_ALLOWED_ORIGIN`
- `APP_DOMAIN`
- `MAX_BODY_MB`
- `OCR_LANGS` (default: `eng+osd`, example: `eng+osd+rus`)
- `RATE_LIMIT_PER_MIN`
- `AUTH_RATE_LIMIT_PER_MIN`
- `GOOGLE_AUTH_ENABLED`

View File

@@ -61,7 +61,14 @@ func makeTestServer(t *testing.T, mutate func(*Config)) *Server {
t.Fatalf("newORMRepo failed: %v", err)
}
return &Server{db: db, orm: orm, config: cfg, storage: storage, limiter: newRateLimiter()}
return &Server{
db: db,
orm: orm,
config: cfg,
storage: storage,
limiter: newRateLimiter(),
searchContent: newSearchContentCache(256),
}
}
func decodeJSONBody[T any](t *testing.T, res *http.Response, out *T) {

View File

@@ -20,6 +20,7 @@ type Config struct {
CORSOrigin string
CookieSecure bool
MaxBodyBytes int64
OCRLangs string
GoogleAuthEnabled bool
GoogleClientID string
@@ -85,6 +86,7 @@ func loadConfig() Config {
CORSOrigin: getEnv("CORS_ALLOWED_ORIGIN", ""),
CookieSecure: getEnv("COOKIE_SECURE", "false") == "true",
MaxBodyBytes: int64(getEnvInt("MAX_BODY_MB", 8)) * 1024 * 1024,
OCRLangs: normalizeOCRLangs(getEnv("OCR_LANGS", "eng+osd")),
GoogleAuthEnabled: getEnv("GOOGLE_AUTH_ENABLED", "false") == "true",
GoogleClientID: getEnv("GOOGLE_CLIENT_ID", ""),
GoogleClientSecret: getEnv("GOOGLE_CLIENT_SECRET", ""),
@@ -177,6 +179,43 @@ func loadConfig() Config {
return cfg
}
func normalizeOCRLangs(v string) string {
fields := strings.FieldsFunc(strings.TrimSpace(strings.ToLower(v)), func(r rune) bool {
return r == '+' || r == ',' || r == ';' || r == '|' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
})
if len(fields) == 0 {
return "eng+osd"
}
seen := make(map[string]struct{}, len(fields))
out := make([]string, 0, len(fields))
for _, field := range fields {
field = strings.TrimSpace(field)
if field == "" {
continue
}
valid := true
for _, ch := range field {
if (ch < 'a' || ch > 'z') && (ch < '0' || ch > '9') && ch != '_' {
valid = false
break
}
}
if !valid {
continue
}
if _, ok := seen[field]; ok {
continue
}
seen[field] = struct{}{}
out = append(out, field)
}
if len(out) == 0 {
return "eng+osd"
}
return strings.Join(out, "+")
}
func applyFTPSLetsEncryptDefaults(cfg *Config) {
if cfg == nil {
return

View File

@@ -31,3 +31,29 @@ func TestApplyFTPSLetsEncryptDefaultsCustomDirAndPreserveManual(t *testing.T) {
t.Fatalf("unexpected key path: %q", cfg.FTPSKeyFile)
}
}
func TestNormalizeOCRLangs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in string
want string
}{
{name: "default on empty", in: "", want: "eng+osd"},
{name: "comma separated", in: "eng, rus, deu", want: "eng+rus+deu"},
{name: "plus separated", in: "eng+osd+rus", want: "eng+osd+rus"},
{name: "dedupe and lowercase", in: "ENG + rus + eng", want: "eng+rus"},
{name: "drops invalid tokens", in: "eng+ru-RU+osd", want: "eng+osd"},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := normalizeOCRLangs(tc.in); got != tc.want {
t.Fatalf("normalizeOCRLangs(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,487 @@
package main
import (
"archive/zip"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/gorilla/mux"
)
func TestAPISearchFilesFuzzyAcrossAllFiles(t *testing.T) {
t.Parallel()
s := makeTestServer(t, nil)
user, err := s.createUser("alice", "password123", "dracula", "auto")
if err != nil {
t.Fatalf("createUser failed: %v", err)
}
if err := s.storage.Mkdir(user.ID, "/docs"); err != nil {
t.Fatalf("mkdir failed: %v", err)
}
if err := s.storage.SaveBytes(user.ID, "/docs/project-plan.md", []byte("# project plan")); err != nil {
t.Fatalf("save bytes failed: %v", err)
}
if err := s.storage.SaveBytes(user.ID, "/notes.txt", []byte("notes")); err != nil {
t.Fatalf("save bytes failed: %v", err)
}
loginReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"alice","password":"password123"}`))
loginReq.Header.Set("Content-Type", "application/json")
loginRec := httptest.NewRecorder()
s.handleLogin(loginRec, loginReq)
if loginRec.Code != http.StatusOK {
t.Fatalf("login status = %d", loginRec.Code)
}
access := cookieByName(loginRec.Result().Cookies(), "access_token")
if access == nil {
t.Fatal("missing access token")
}
req := httptest.NewRequest(http.MethodGet, "/api/files/search?q=prjpln&limit=10", nil)
req.AddCookie(access)
rec := httptest.NewRecorder()
s.authMiddleware(http.HandlerFunc(s.handleSearchFiles)).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("search status = %d, want %d", rec.Code, http.StatusOK)
}
var out struct {
Entries []FileEntry `json:"entries"`
}
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
t.Fatalf("decode search response failed: %v", err)
}
if len(out.Entries) == 0 {
t.Fatal("expected at least one search result")
}
if out.Entries[0].Path != "/docs/project-plan.md" {
t.Fatalf("top search result = %q, want %q", out.Entries[0].Path, "/docs/project-plan.md")
}
}
func TestSharePageReturnsPublicURLAndDiscordMeta(t *testing.T) {
t.Parallel()
s := makeTestServer(t, nil)
user, err := s.createUser("alice", "password123", "dracula", "auto")
if err != nil {
t.Fatalf("createUser failed: %v", err)
}
if err := s.storage.SaveBytes(user.ID, "/cover.png", []byte("png-data")); err != nil {
t.Fatalf("save bytes failed: %v", err)
}
loginReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"alice","password":"password123"}`))
loginReq.Header.Set("Content-Type", "application/json")
loginRec := httptest.NewRecorder()
s.handleLogin(loginRec, loginReq)
if loginRec.Code != http.StatusOK {
t.Fatalf("login status = %d", loginRec.Code)
}
access := cookieByName(loginRec.Result().Cookies(), "access_token")
if access == nil {
t.Fatal("missing access token")
}
createReq := httptest.NewRequest(http.MethodPost, "/api/files/share", bytes.NewBufferString(`{"path":"/cover.png","expiresMinutes":60}`))
createReq.Header.Set("Content-Type", "application/json")
createReq.AddCookie(access)
createRec := httptest.NewRecorder()
s.authMiddleware(http.HandlerFunc(s.handleCreateShareLink)).ServeHTTP(createRec, createReq)
if createRec.Code != http.StatusCreated {
t.Fatalf("create share status = %d, want %d", createRec.Code, http.StatusCreated)
}
var shareResp struct {
URL string `json:"url"`
Token string `json:"token"`
}
if err := json.NewDecoder(createRec.Body).Decode(&shareResp); err != nil {
t.Fatalf("decode share response failed: %v", err)
}
if !strings.Contains(shareResp.URL, "/share/") {
t.Fatalf("share url = %q, want public /share/ URL", shareResp.URL)
}
router := mux.NewRouter()
router.HandleFunc("/share/{token}", s.handleSharedPage).Methods(http.MethodGet)
pageReq := httptest.NewRequest(http.MethodGet, "/share/"+shareResp.Token, nil)
pageRec := httptest.NewRecorder()
router.ServeHTTP(pageRec, pageReq)
if pageRec.Code != http.StatusOK {
t.Fatalf("share page status = %d, want %d", pageRec.Code, http.StatusOK)
}
body := pageRec.Body.String()
if !strings.Contains(body, `property="og:title"`) {
t.Fatal("share page is missing og:title metadata")
}
if !strings.Contains(body, "/api/share/"+shareResp.Token+"/preview") {
t.Fatal("share page is missing preview metadata URL")
}
}
func TestAPISearchFilesFindsPlainTextContent(t *testing.T) {
t.Parallel()
s := makeTestServer(t, nil)
user, err := s.createUser("alice", "password123", "dracula", "auto")
if err != nil {
t.Fatalf("createUser failed: %v", err)
}
if err := s.storage.SaveBytes(user.ID, "/docs/meeting-notes.txt", []byte("Budget approval is scheduled for Monday morning.")); err != nil {
t.Fatalf("save bytes failed: %v", err)
}
access := loginAccessToken(t, s, "alice", "password123")
entries := runSearchRequest(t, s, access, "budget approval")
if len(entries) == 0 {
t.Fatal("expected at least one search result")
}
if entries[0].Path != "/docs/meeting-notes.txt" {
t.Fatalf("top search result = %q, want %q", entries[0].Path, "/docs/meeting-notes.txt")
}
}
func TestAPISearchFilesFindsDocxContent(t *testing.T) {
t.Parallel()
s := makeTestServer(t, nil)
user, err := s.createUser("alice", "password123", "dracula", "auto")
if err != nil {
t.Fatalf("createUser failed: %v", err)
}
docPath := filepath.Join(t.TempDir(), "proposal.docx")
writeDocxFixture(t, docPath, "Quarterly roadmap milestone")
data, err := os.ReadFile(docPath)
if err != nil {
t.Fatalf("read docx failed: %v", err)
}
if err := s.storage.SaveBytes(user.ID, "/docs/proposal.docx", data); err != nil {
t.Fatalf("save bytes failed: %v", err)
}
access := loginAccessToken(t, s, "alice", "password123")
entries := runSearchRequest(t, s, access, "roadmap milestone")
if len(entries) == 0 {
t.Fatal("expected at least one search result")
}
if entries[0].Path != "/docs/proposal.docx" {
t.Fatalf("top search result = %q, want %q", entries[0].Path, "/docs/proposal.docx")
}
}
func TestAPISearchFilesFindsImageOCRContent(t *testing.T) {
t.Parallel()
if _, err := exec.LookPath("tesseract"); err != nil {
t.Skip("tesseract not installed")
}
convertBinary, err := exec.LookPath("convert")
if err != nil {
t.Skip("ImageMagick convert not installed")
}
s := makeTestServer(t, nil)
user, err := s.createUser("alice", "password123", "dracula", "auto")
if err != nil {
t.Fatalf("createUser failed: %v", err)
}
imagePath := filepath.Join(t.TempDir(), "searchable.png")
cmd := exec.Command(convertBinary,
"-background", "white",
"-fill", "black",
"-font", "DejaVu-Sans-Bold",
"-pointsize", "96",
"-size", "1400x280",
"-gravity", "center",
"label:SEARCHABLE",
imagePath,
)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("convert failed: %v (%s)", err, strings.TrimSpace(string(out)))
}
data, err := os.ReadFile(imagePath)
if err != nil {
t.Fatalf("read image failed: %v", err)
}
if err := s.storage.SaveBytes(user.ID, "/images/searchable.png", data); err != nil {
t.Fatalf("save bytes failed: %v", err)
}
access := loginAccessToken(t, s, "alice", "password123")
entries := runSearchRequest(t, s, access, "searchable")
if len(entries) == 0 {
t.Fatal("expected at least one search result")
}
found := false
for _, entry := range entries {
if entry.Path == "/images/searchable.png" {
found = true
break
}
}
if !found {
t.Fatalf("expected OCR result for %q in search results", "/images/searchable.png")
}
}
func TestAPISearchFilesUsesPersistedOCRCache(t *testing.T) {
t.Parallel()
s := makeTestServer(t, nil)
user, err := s.createUser("alice", "password123", "dracula", "auto")
if err != nil {
t.Fatalf("createUser failed: %v", err)
}
if err := s.storage.SaveBytes(user.ID, "/images/cached.png", []byte("not-a-real-image")); err != nil {
t.Fatalf("save bytes failed: %v", err)
}
meta, err := s.storage.Stat(user.ID, "/images/cached.png")
if err != nil {
t.Fatalf("stat failed: %v", err)
}
if _, err := s.db.Exec(
`INSERT INTO search_content_cache(user_id, rel_path, extractor, file_size, mod_time_ns, content) VALUES (?, ?, ?, ?, ?, ?)`,
user.ID,
"/images/cached.png",
"ocr",
meta.Size,
meta.ModTime.UTC().UnixNano(),
"vault phrase",
); err != nil {
t.Fatalf("insert search cache failed: %v", err)
}
s.searchContent = newSearchContentCache(256)
access := loginAccessToken(t, s, "alice", "password123")
entries := runSearchRequest(t, s, access, "vault phrase")
if len(entries) == 0 {
t.Fatal("expected persisted OCR cache search result")
}
if entries[0].Path != "/images/cached.png" {
t.Fatalf("top search result = %q, want %q", entries[0].Path, "/images/cached.png")
}
}
func TestAPIContentPreviewReturnsDocxText(t *testing.T) {
t.Parallel()
s := makeTestServer(t, nil)
user, err := s.createUser("alice", "password123", "dracula", "auto")
if err != nil {
t.Fatalf("createUser failed: %v", err)
}
docPath := filepath.Join(t.TempDir(), "preview.docx")
writeDocxFixture(t, docPath, "Quarterly preview memo")
data, err := os.ReadFile(docPath)
if err != nil {
t.Fatalf("read docx failed: %v", err)
}
if err := s.storage.SaveBytes(user.ID, "/docs/preview.docx", data); err != nil {
t.Fatalf("save bytes failed: %v", err)
}
access := loginAccessToken(t, s, "alice", "password123")
req := httptest.NewRequest(http.MethodGet, "/api/files/content-preview?path="+url.QueryEscape("/docs/preview.docx"), nil)
req.AddCookie(access)
rec := httptest.NewRecorder()
s.authMiddleware(http.HandlerFunc(s.handleContentPreview)).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("preview status = %d, want %d", rec.Code, http.StatusOK)
}
var out struct {
Content string `json:"content"`
}
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
t.Fatalf("decode preview response failed: %v", err)
}
if !strings.Contains(out.Content, "Quarterly preview memo") {
t.Fatalf("preview content = %q, want extracted doc text", out.Content)
}
}
func TestAPIThumbnailReturnsDocxPreviewImage(t *testing.T) {
if _, err := exec.LookPath("soffice"); err != nil {
t.Skip("soffice not installed")
}
if _, err := exec.LookPath("pdftoppm"); err != nil {
t.Skip("pdftoppm not installed")
}
if _, err := exec.LookPath("convert"); err != nil {
t.Skip("ImageMagick convert not installed")
}
s := makeTestServer(t, nil)
user, err := s.createUser("alice", "password123", "dracula", "auto")
if err != nil {
t.Fatalf("createUser failed: %v", err)
}
docPath := filepath.Join(t.TempDir(), "thumbnail.docx")
writeDocxFixture(t, docPath, "Quarterly thumbnail memo")
data, err := os.ReadFile(docPath)
if err != nil {
t.Fatalf("read docx failed: %v", err)
}
if err := s.storage.SaveBytes(user.ID, "/docs/thumbnail.docx", data); err != nil {
t.Fatalf("save bytes failed: %v", err)
}
access := loginAccessToken(t, s, "alice", "password123")
req := httptest.NewRequest(http.MethodGet, "/api/files/thumbnail?path="+url.QueryEscape("/docs/thumbnail.docx"), nil)
req.AddCookie(access)
rec := httptest.NewRecorder()
s.authMiddleware(http.HandlerFunc(s.handleThumbnail)).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("thumbnail status = %d, want %d (%s)", rec.Code, http.StatusOK, strings.TrimSpace(rec.Body.String()))
}
if ctype := rec.Header().Get("Content-Type"); !strings.HasPrefix(ctype, "image/png") {
t.Fatalf("thumbnail content-type = %q, want image/png", ctype)
}
if rec.Body.Len() == 0 {
t.Fatal("expected non-empty thumbnail image")
}
}
func TestAPIThumbnailUsesPersistedCache(t *testing.T) {
t.Parallel()
s := makeTestServer(t, nil)
user, err := s.createUser("alice", "password123", "dracula", "auto")
if err != nil {
t.Fatalf("createUser failed: %v", err)
}
if err := s.storage.SaveBytes(user.ID, "/docs/cached.docx", []byte("not-a-real-docx")); err != nil {
t.Fatalf("save bytes failed: %v", err)
}
meta, err := s.storage.Stat(user.ID, "/docs/cached.docx")
if err != nil {
t.Fatalf("stat failed: %v", err)
}
cachedImage := []byte{
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0x00,
0x00, 0x03, 0x01, 0x01, 0x00, 0xc9, 0xfe, 0x92,
0xef, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e,
0x44, 0xae, 0x42, 0x60, 0x82,
}
if _, err := s.db.Exec(
`INSERT INTO preview_thumbnail_cache(user_id, rel_path, renderer, file_size, mod_time_ns, content_type, image) VALUES (?, ?, ?, ?, ?, ?, ?)`,
user.ID,
"/docs/cached.docx",
cacheableThumbnailRenderer(FileEntry{Name: "cached.docx"}),
meta.Size,
meta.ModTime.UTC().UnixNano(),
"image/png",
cachedImage,
); err != nil {
t.Fatalf("insert thumbnail cache failed: %v", err)
}
access := loginAccessToken(t, s, "alice", "password123")
req := httptest.NewRequest(http.MethodGet, "/api/files/thumbnail?path="+url.QueryEscape("/docs/cached.docx"), nil)
req.AddCookie(access)
rec := httptest.NewRecorder()
s.authMiddleware(http.HandlerFunc(s.handleThumbnail)).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("thumbnail status = %d, want %d (%s)", rec.Code, http.StatusOK, strings.TrimSpace(rec.Body.String()))
}
if !bytes.Equal(rec.Body.Bytes(), cachedImage) {
t.Fatal("thumbnail response did not use persisted cache bytes")
}
}
func loginAccessToken(t *testing.T, s *Server, username, password string) *http.Cookie {
t.Helper()
loginReq := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(`{"username":"`+username+`","password":"`+password+`"}`))
loginReq.Header.Set("Content-Type", "application/json")
loginRec := httptest.NewRecorder()
s.handleLogin(loginRec, loginReq)
if loginRec.Code != http.StatusOK {
t.Fatalf("login status = %d", loginRec.Code)
}
access := cookieByName(loginRec.Result().Cookies(), "access_token")
if access == nil {
t.Fatal("missing access token")
}
return access
}
func runSearchRequest(t *testing.T, s *Server, access *http.Cookie, query string) []FileEntry {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/api/files/search?q="+url.QueryEscape(query)+"&limit=20", nil)
req.AddCookie(access)
rec := httptest.NewRecorder()
s.authMiddleware(http.HandlerFunc(s.handleSearchFiles)).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("search status = %d, want %d", rec.Code, http.StatusOK)
}
var out struct {
Entries []FileEntry `json:"entries"`
}
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil {
t.Fatalf("decode search response failed: %v", err)
}
return out.Entries
}
func writeDocxFixture(t *testing.T, filePath, text string) {
t.Helper()
f, err := os.Create(filePath)
if err != nil {
t.Fatalf("create docx failed: %v", err)
}
defer f.Close()
zw := zip.NewWriter(f)
writeZipFixtureFile(t, zw, "[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>`)
writeZipFixtureFile(t, zw, "_rels/.rels", `<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>`)
writeZipFixtureFile(t, zw, "word/document.xml", `<?xml version="1.0" encoding="UTF-8"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:body>
<w:p><w:r><w:t>`+text+`</w:t></w:r></w:p>
</w:body>
</w:document>`)
if err := zw.Close(); err != nil {
t.Fatalf("close docx failed: %v", err)
}
}
func writeZipFixtureFile(t *testing.T, zw *zip.Writer, name, content string) {
t.Helper()
w, err := zw.Create(name)
if err != nil {
t.Fatalf("create zip entry %q failed: %v", name, err)
}
if _, err := w.Write([]byte(content)); err != nil {
t.Fatalf("write zip entry %q failed: %v", name, err)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -49,7 +49,6 @@ export default function TransferSection(props: Props) {
onDownloadSelected,
} = 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',
@@ -60,15 +59,9 @@ export default function TransferSection(props: Props) {
return (
<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>
<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>
<div className="brutal-block p-5">
<div className="flex flex-wrap items-center justify-between gap-4">
<p className="font-display text-4xl">{username}</p>
{selectedCount > 0 ? <span className="brutal-chip">{selectedCount} {t('selected')}</span> : null}
</div>
</div>

View File

@@ -13,7 +13,7 @@ export default {
},
extend: {
screens: {
lg: '600px',
wide: '1400px',
},
colors: {
background: 'hsl(var(--background))',