Compare commits

..

9 Commits

Author SHA1 Message Date
467bd88f95 fix: SSR error - check for browser before accessing navigator
- Add typeof window check in I18nContext useEffect
- Safely access navigator.language with optional chaining
2026-01-19 23:41:45 +01:00
d75a30f459 fix: Two-row header layout for mobile
- Row 1: Logo + Logout (always fits)
- Row 2: Session info (left) + Status badges (right)
- No more horizontal scrolling on mobile
2026-01-19 23:31:49 +01:00
06fa208dd8 feat: Auto browser language detection, remove LanguageSwitcher
- Update I18nContext to auto-detect browser language
- Default to English, switch to German if browser is German
- Remove LanguageSwitcher from guest and authenticated views
- Remove DramOfTheDay from header
- Cleaner, mobile-friendly header layout
2026-01-19 23:26:55 +01:00
883f76e488 fix: Restore logout button, hide DramOfTheDay on mobile
- Keep logout button in header (user feedback)
- Hide DramOfTheDay on mobile to save space (hidden sm:block)
- Keep responsive flex-wrap and reduced gaps
2026-01-19 23:23:28 +01:00
d8a9e9fd0a fix: Make header responsive on mobile
- Add flex-wrap to header right section
- Hide LanguageSwitcher on small screens (hidden sm:block)
- Replace logout text with Settings icon button
- Add shrink-0 to prevent logo compression
- Reduce gaps on mobile (gap-1 sm:gap-2)
2026-01-19 23:21:32 +01:00
5c00be59f1 feat: Add UX optimizations - skeletons and optimistic hooks
- Add Skeletons.tsx with TastingListSkeleton, ChartSkeleton, etc.
- Add useOptimistic.ts hooks for React 19 optimistic updates
- Update stats page to use skeleton loading instead of spinner
- Remove force-dynamic exports (12 files) for SSG compatibility
- Note: PPR (cacheComponents) tested but reverted - requires RSC-first refactor
2026-01-19 23:01:00 +01:00
004698b604 feat: Enable React Compiler for automatic memoization
- Install babel-plugin-react-compiler@1.0.0
- Add reactCompiler: true to next.config.mjs
- React 19 compiler will auto-optimize useMemo/useCallback
2026-01-19 22:47:01 +01:00
096daffb3e feat: Upgrade to Tailwind CSS v4.1.18
- Migrate from tailwindcss v3.3 to v4.1.18
- Replace @tailwind directives with @import 'tailwindcss'
- Move custom colors to @theme block in globals.css
- Convert custom utilities to @utility syntax
- Update PostCSS config to use @tailwindcss/postcss
- Remove autoprefixer (now built-in)
2026-01-19 22:26:21 +01:00
b179a88d4c chore: Prepare for Tailwind v4 migration 2026-01-19 22:17:48 +01:00
77 changed files with 1117 additions and 765 deletions

1
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1 @@
{}

View File

@@ -160,7 +160,7 @@
"oily", "oily",
"medium-full body", "medium-full body",
"silky", "silky",
"rounded", "rounded-sm",
"balanced", "balanced",
"smooth", "smooth",
"slightly warming", "slightly warming",
@@ -774,7 +774,7 @@
], ],
"texture": [ "texture": [
"oily and waxy", "oily and waxy",
"creamy and rounded", "creamy and rounded-sm",
"medium weight", "medium weight",
"silky with a gentle grip", "silky with a gentle grip",
"well-structured balancing sweetness and dryness", "well-structured balancing sweetness and dryness",
@@ -880,7 +880,7 @@
"oily", "oily",
"viscous", "viscous",
"silky", "silky",
"rounded", "rounded-sm",
"balanced", "balanced",
"medium-bodied", "medium-bodied",
"slightly-dry", "slightly-dry",
@@ -1038,7 +1038,7 @@
"polished", "polished",
"well-integrated", "well-integrated",
"slightly oily", "slightly oily",
"rounded", "rounded-sm",
"smooth", "smooth",
"lively" "lively"
] ]
@@ -1235,7 +1235,7 @@
"softly effervescent", "softly effervescent",
"polished oak feel", "polished oak feel",
"refreshingly bright", "refreshingly bright",
"lean yet rounded" "lean yet rounded-sm"
] ]
}, },
"Aultmore": { "Aultmore": {
@@ -1527,7 +1527,7 @@
"creamy", "creamy",
"silky", "silky",
"oily", "oily",
"rounded", "rounded-sm",
"polished", "polished",
"mellow", "mellow",
"gentle", "gentle",
@@ -1588,7 +1588,7 @@
"Lingering sea-salt", "Lingering sea-salt",
"Sweet lemon", "Sweet lemon",
"Lime zest", "Lime zest",
"Dried apple ring", "Dried apple ring-3",
"Vanilla oak", "Vanilla oak",
"Gentle oak tannin", "Gentle oak tannin",
"Black pepper", "Black pepper",
@@ -1883,7 +1883,7 @@
"polished", "polished",
"slightly resinous", "slightly resinous",
"lively oak spice", "lively oak spice",
"rounded", "rounded-sm",
"structured", "structured",
"sappy (young oak feel)" "sappy (young oak feel)"
] ]
@@ -2001,7 +2001,7 @@
"oily", "oily",
"creamy", "creamy",
"silky", "silky",
"rounded", "rounded-sm",
"medium-bodied", "medium-bodied",
"polished", "polished",
"gently spirity", "gently spirity",
@@ -2156,7 +2156,7 @@
"oily", "oily",
"creamy", "creamy",
"medium to full-bodied", "medium to full-bodied",
"rounded", "rounded-sm",
"silky", "silky",
"slightly waxy", "slightly waxy",
"balanced", "balanced",
@@ -2415,7 +2415,7 @@
"grippy", "grippy",
"structured tannins", "structured tannins",
"cask-driven", "cask-driven",
"rounded", "rounded-sm",
"balanced", "balanced",
"robust", "robust",
"powerful", "powerful",
@@ -2544,7 +2544,7 @@
"waxy", "waxy",
"silky", "silky",
"rich", "rich",
"rounded", "rounded-sm",
"medium-to-full bodied", "medium-to-full bodied",
"coating", "coating",
"smooth", "smooth",
@@ -2781,7 +2781,7 @@
"light-to-medium body", "light-to-medium body",
"soft", "soft",
"polished", "polished",
"rounded", "rounded-sm",
"well-structured", "well-structured",
"approachable", "approachable",
"gentle", "gentle",
@@ -2868,7 +2868,7 @@
"creamy toffee and caramelized sugar", "creamy toffee and caramelized sugar",
"vanilla oak spicing (cinnamon, white pepper)", "vanilla oak spicing (cinnamon, white pepper)",
"nutty undertones (almond, hazelnut)", "nutty undertones (almond, hazelnut)",
"soft rounded bitterness (cocoa nibs, orange marmalade)", "soft rounded-sm bitterness (cocoa nibs, orange marmalade)",
"mild stone fruit (apricot) and dried sultana", "mild stone fruit (apricot) and dried sultana",
"waxy orchard skin texture and gentle oiliness", "waxy orchard skin texture and gentle oiliness",
"cereal maltiness with a slight biscuit edge", "cereal maltiness with a slight biscuit edge",
@@ -2888,7 +2888,7 @@
], ],
"texture": [ "texture": [
"waxy mouthfeel that softens with age", "waxy mouthfeel that softens with age",
"creamy and rounded", "creamy and rounded-sm",
"oily yet clean, not heavy", "oily yet clean, not heavy",
"slightly coating but not syrupy", "slightly coating but not syrupy",
"polished oak structure underneath", "polished oak structure underneath",
@@ -2992,7 +2992,7 @@
"polished oak", "polished oak",
"soft tannins", "soft tannins",
"gentle heat", "gentle heat",
"rounded", "rounded-sm",
"clean", "clean",
"crisp", "crisp",
"gliding", "gliding",
@@ -3591,7 +3591,7 @@
"silky", "silky",
"medium-bodied", "medium-bodied",
"slightly oily", "slightly oily",
"rounded", "rounded-sm",
"well-integrated alcohol", "well-integrated alcohol",
"polished tannins", "polished tannins",
"sprightly", "sprightly",
@@ -3820,7 +3820,7 @@
"viscous", "viscous",
"creamy", "creamy",
"velvety", "velvety",
"rounded mouthfeel", "rounded-sm mouthfeel",
"slightly chewy", "slightly chewy",
"resinous", "resinous",
"warming", "warming",
@@ -4776,7 +4776,7 @@
"silky", "silky",
"oily", "oily",
"creamy", "creamy",
"rounded", "rounded-sm",
"balanced", "balanced",
"medium-bodied", "medium-bodied",
"spry", "spry",
@@ -5066,7 +5066,7 @@
"well-balanced", "well-balanced",
"medium-to-full body", "medium-to-full body",
"coating", "coating",
"rounded", "rounded-sm",
"polished", "polished",
"lively", "lively",
"gentle", "gentle",
@@ -5424,7 +5424,7 @@
"smooth", "smooth",
"creamy", "creamy",
"soft", "soft",
"rounded", "rounded-sm",
"medium-bodied", "medium-bodied",
"polished", "polished",
"well-balanced", "well-balanced",
@@ -5602,7 +5602,7 @@
"quiet floral echo (heather)" "quiet floral echo (heather)"
], ],
"texture": [ "texture": [
"creamy and rounded", "creamy and rounded-sm",
"silky with gentle oiliness", "silky with gentle oiliness",
"medium body, never heavy", "medium body, never heavy",
"polished oak grip (fine-grained tannins)", "polished oak grip (fine-grained tannins)",
@@ -5861,7 +5861,7 @@
"oily", "oily",
"creamy", "creamy",
"velvety", "velvety",
"rounded", "rounded-sm",
"medium-bodied", "medium-bodied",
"approachable", "approachable",
"slightly waxy", "slightly waxy",
@@ -6002,7 +6002,7 @@
"smooth", "smooth",
"silky", "silky",
"slightly waxy", "slightly waxy",
"rounded", "rounded-sm",
"gentle", "gentle",
"well-balanced", "well-balanced",
"not overly viscous", "not overly viscous",
@@ -6070,7 +6070,7 @@
"silky", "silky",
"oily (light)", "oily (light)",
"soft", "soft",
"rounded", "rounded-sm",
"clean", "clean",
"crisp", "crisp",
"approachable", "approachable",
@@ -6230,7 +6230,7 @@
"waxy", "waxy",
"silky", "silky",
"medium-weight", "medium-weight",
"rounded", "rounded-sm",
"well-structured", "well-structured",
"balanced", "balanced",
"clean", "clean",
@@ -6427,7 +6427,7 @@
"waxy-coated", "waxy-coated",
"chalky/mineral grip", "chalky/mineral grip",
"well-structured", "well-structured",
"rounded and balanced", "rounded-sm and balanced",
"smooth entry", "smooth entry",
"zesty lift", "zesty lift",
"slightly drying oak" "slightly drying oak"
@@ -6507,7 +6507,7 @@
"waxy", "waxy",
"silky", "silky",
"medium-bodied", "medium-bodied",
"rounded", "rounded-sm",
"slightly resinous", "slightly resinous",
"prickly spice", "prickly spice",
"chewy", "chewy",
@@ -7021,7 +7021,7 @@
"oily", "oily",
"waxy", "waxy",
"coastal mouthfeel", "coastal mouthfeel",
"rounded", "rounded-sm",
"balanced", "balanced",
"creamy", "creamy",
"silky", "silky",
@@ -7265,7 +7265,7 @@
"smooth and approachable", "smooth and approachable",
"medium-bodied", "medium-bodied",
"slightly oily", "slightly oily",
"soft and rounded", "soft and rounded-sm",
"creamy", "creamy",
"well-integrated alcohol", "well-integrated alcohol",
"gentle spice", "gentle spice",
@@ -7648,7 +7648,7 @@
"Slightly prickly", "Slightly prickly",
"Weighty yet agile", "Weighty yet agile",
"Chewy", "Chewy",
"Soft and rounded", "Soft and rounded-sm",
"Peppery heat" "Peppery heat"
] ]
}, },
@@ -7949,7 +7949,7 @@
"waxy / coating", "waxy / coating",
"oily", "oily",
"medium-bodied", "medium-bodied",
"softly rounded", "softly rounded-sm",
"creamy", "creamy",
"silky", "silky",
"slightly drying", "slightly drying",
@@ -8003,7 +8003,7 @@
"oily and resinous", "oily and resinous",
"creamy and luscious", "creamy and luscious",
"viscous mouthfeel", "viscous mouthfeel",
"rounded and polished", "rounded-sm and polished",
"silky with grip", "silky with grip",
"balanced warmth", "balanced warmth",
"velvety oak", "velvety oak",
@@ -8145,7 +8145,7 @@
"silky", "silky",
"creamy", "creamy",
"oily", "oily",
"rounded", "rounded-sm",
"plush", "plush",
"well-integrated", "well-integrated",
"velvety", "velvety",
@@ -8258,12 +8258,12 @@
"dry, gently cereal/biscuity tail", "dry, gently cereal/biscuity tail",
"clean, crisp acidity (a touch of citrus)", "clean, crisp acidity (a touch of citrus)",
"overall dryness in later stages", "overall dryness in later stages",
"no sulphur, very smooth and rounded" "no sulphur, very smooth and rounded-sm"
], ],
"texture": [ "texture": [
"smooth and approachable", "smooth and approachable",
"medium-bodied", "medium-bodied",
"creamy and rounded", "creamy and rounded-sm",
"silky and polished", "silky and polished",
"slightly oily in the glass but light on the palate", "slightly oily in the glass but light on the palate",
"well-balanced", "well-balanced",
@@ -8319,7 +8319,7 @@
"smooth with a gentle prickle", "smooth with a gentle prickle",
"soft and approachable", "soft and approachable",
"salty tactile impression", "salty tactile impression",
"rounded oak structure without heaviness", "rounded-sm oak structure without heaviness",
"clean, brisk progression across the palate" "clean, brisk progression across the palate"
] ]
}, },
@@ -8758,7 +8758,7 @@
"silky", "silky",
"slightly chewy", "slightly chewy",
"polished", "polished",
"rounded", "rounded-sm",
"balanced", "balanced",
"approachable", "approachable",
"maritime grip", "maritime grip",
@@ -9322,7 +9322,7 @@
"creamy", "creamy",
"oily", "oily",
"waxy", "waxy",
"rounded", "rounded-sm",
"smooth", "smooth",
"soft", "soft",
"mouth-coating", "mouth-coating",
@@ -9687,7 +9687,7 @@
"polished", "polished",
"slightly oily", "slightly oily",
"creamy", "creamy",
"rounded", "rounded-sm",
"well-balanced", "well-balanced",
"slightly drying", "slightly drying",
"crisp", "crisp",
@@ -9850,7 +9850,7 @@
"silky", "silky",
"creamy", "creamy",
"velvety", "velvety",
"rounded", "rounded-sm",
"medium-bodied", "medium-bodied",
"oily", "oily",
"polished", "polished",
@@ -9919,7 +9919,7 @@
"Clean and medium length", "Clean and medium length",
"Subtle coconut", "Subtle coconut",
"A touch of herbal freshness", "A touch of herbal freshness",
"Smooth and rounded close", "Smooth and rounded-sm close",
"Fading floral note" "Fading floral note"
], ],
"texture": [ "texture": [
@@ -9929,7 +9929,7 @@
"Soft and approachable", "Soft and approachable",
"Slightly oily", "Slightly oily",
"Polished", "Polished",
"Even and rounded", "Even and rounded-sm",
"Mellow", "Mellow",
"Clean and fresh", "Clean and fresh",
"Non-aggressive" "Non-aggressive"
@@ -10140,7 +10140,7 @@
"creamy", "creamy",
"well-integrated alcohol", "well-integrated alcohol",
"polished oak influence", "polished oak influence",
"soft and rounded", "soft and rounded-sm",
"gently warming", "gently warming",
"bright and lively", "bright and lively",
"clean and crisp" "clean and crisp"
@@ -10267,7 +10267,7 @@
"prickly pepper", "prickly pepper",
"well-integrated heat", "well-integrated heat",
"chewy", "chewy",
"rounded", "rounded-sm",
"silky", "silky",
"moderately weighted" "moderately weighted"
] ]
@@ -10409,7 +10409,7 @@
"medium-bodied", "medium-bodied",
"well-integrated alcohol", "well-integrated alcohol",
"polished oak", "polished oak",
"rounded", "rounded-sm",
"luscious", "luscious",
"slightly viscous", "slightly viscous",
"smooth" "smooth"
@@ -10749,7 +10749,7 @@
"thick", "thick",
"viscous", "viscous",
"mouth-coating", "mouth-coating",
"rounded", "rounded-sm",
"well-integrated", "well-integrated",
"polished", "polished",
"slightly drying", "slightly drying",
@@ -10845,7 +10845,7 @@
"smooth", "smooth",
"fresh", "fresh",
"sprightly", "sprightly",
"rounded" "rounded-sm"
] ]
}, },
"Jameson": { "Jameson": {
@@ -10920,7 +10920,7 @@
"creamy", "creamy",
"velvety", "velvety",
"oily", "oily",
"rounded", "rounded-sm",
"mellow", "mellow",
"balanced", "balanced",
"soft", "soft",
@@ -11185,7 +11185,7 @@
"waxy, candle-wax polish tone in older bottlings" "waxy, candle-wax polish tone in older bottlings"
], ],
"texture": [ "texture": [
"creamy, silky and rounded mouthfeel", "creamy, silky and rounded-sm mouthfeel",
"viscous and coating (especially single pot still proofs)", "viscous and coating (especially single pot still proofs)",
"slightly oily and waxy", "slightly oily and waxy",
"buttery and smooth (vanilla custard texture)", "buttery and smooth (vanilla custard texture)",
@@ -11489,7 +11489,7 @@
], ],
"texture": [ "texture": [
"Creamy", "Creamy",
"Silky and rounded", "Silky and rounded-sm",
"Medium viscosity", "Medium viscosity",
"Oiliness that coats the palate", "Oiliness that coats the palate",
"Soft and approachable", "Soft and approachable",
@@ -11868,7 +11868,7 @@
"well-balanced", "well-balanced",
"lightly oily yet clean", "lightly oily yet clean",
"medium-bodied", "medium-bodied",
"rounded and cohesive", "rounded-sm and cohesive",
"soft-spiced", "soft-spiced",
"refreshing acidity", "refreshing acidity",
"velvety oak impression", "velvety oak impression",
@@ -12021,7 +12021,7 @@
"elegant and restrained", "elegant and restrained",
"refined and precise", "refined and precise",
"high-definition clarity", "high-definition clarity",
"smooth, rounded edges", "smooth, rounded-sm edges",
"spry acidity (citrus lift)", "spry acidity (citrus lift)",
"tight-grained oak feel", "tight-grained oak feel",
"lifted and airy", "lifted and airy",
@@ -12044,7 +12044,7 @@
"hint of coconut and banana from Mizunara/inactive oak" "hint of coconut and banana from Mizunara/inactive oak"
], ],
"taste": [ "taste": [
"soft, rounded mouthfeel", "soft, rounded-sm mouthfeel",
"orchard fruit sweetness (pear, apple)", "orchard fruit sweetness (pear, apple)",
"peach and apricot preserve", "peach and apricot preserve",
"light floral notes (lilac, jasmine)", "light floral notes (lilac, jasmine)",
@@ -12072,7 +12072,7 @@
"texture": [ "texture": [
"silky and smooth", "silky and smooth",
"light to medium body", "light to medium body",
"crisp yet rounded", "crisp yet rounded-sm",
"polished and clean", "polished and clean",
"well-integrated alcohol", "well-integrated alcohol",
"slightly oily with a fresh core", "slightly oily with a fresh core",
@@ -12226,7 +12226,7 @@
"Subtle smoke/char - faint ex-bourbon and Mizunara influence" "Subtle smoke/char - faint ex-bourbon and Mizunara influence"
], ],
"taste": [ "taste": [
"Velvety malt - soft, rounded mid-palate", "Velvety malt - soft, rounded-sm mid-palate",
"Pear and Nashi fruit - clean orchard sweetness", "Pear and Nashi fruit - clean orchard sweetness",
"White peach and apricot - gentle stone fruit", "White peach and apricot - gentle stone fruit",
"Citrus zest - lemon and yuzu acidity for balance", "Citrus zest - lemon and yuzu acidity for balance",
@@ -12615,7 +12615,7 @@
"creamy", "creamy",
"silky", "silky",
"smooth", "smooth",
"rounded", "rounded-sm",
"balanced", "balanced",
"firm", "firm",
"robust", "robust",
@@ -12764,7 +12764,7 @@
"silky", "silky",
"full-bodied", "full-bodied",
"rich", "rich",
"rounded", "rounded-sm",
"chewy", "chewy",
"polished", "polished",
"luscious", "luscious",
@@ -12848,7 +12848,7 @@
"rich", "rich",
"viscous", "viscous",
"structured", "structured",
"rounded", "rounded-sm",
"bold", "bold",
"smooth", "smooth",
"dense", "dense",
@@ -12985,7 +12985,7 @@
"chewy", "chewy",
"well-structured", "well-structured",
"balanced", "balanced",
"rounded", "rounded-sm",
"warming", "warming",
"spicy prickle", "spicy prickle",
"smooth", "smooth",
@@ -13067,7 +13067,7 @@
"hot and vibrant", "hot and vibrant",
"slightly prickly", "slightly prickly",
"buttery", "buttery",
"rounded and integrated", "rounded-sm and integrated",
"unctuous", "unctuous",
"mouth-filling", "mouth-filling",
"thick pour" "thick pour"
@@ -13176,7 +13176,7 @@
"dense", "dense",
"full-bodied", "full-bodied",
"rich", "rich",
"rounded", "rounded-sm",
"well-integrated", "well-integrated",
"balanced", "balanced",
"layered", "layered",
@@ -13273,7 +13273,7 @@
"creamy", "creamy",
"oily", "oily",
"medium-bodied", "medium-bodied",
"rounded", "rounded-sm",
"slightly spicy", "slightly spicy",
"warm", "warm",
"velvety", "velvety",
@@ -13338,7 +13338,7 @@
"luscious", "luscious",
"chewy", "chewy",
"rich", "rich",
"rounded" "rounded-sm"
] ]
}, },
"Elijah Craig": { "Elijah Craig": {
@@ -13719,7 +13719,7 @@
"chewy", "chewy",
"syrupy", "syrupy",
"rich", "rich",
"rounded", "rounded-sm",
"coating", "coating",
"smooth", "smooth",
"balanced", "balanced",
@@ -13821,7 +13821,7 @@
"velvety", "velvety",
"silky", "silky",
"chewy", "chewy",
"rounded", "rounded-sm",
"well-integrated", "well-integrated",
"balanced", "balanced",
"smooth", "smooth",
@@ -13966,7 +13966,7 @@
"smooth", "smooth",
"creamy", "creamy",
"oily", "oily",
"rounded", "rounded-sm",
"balanced", "balanced",
"mellow", "mellow",
"slightly viscous", "slightly viscous",
@@ -14118,7 +14118,7 @@
"creamy", "creamy",
"velvety", "velvety",
"oily", "oily",
"rounded", "rounded-sm",
"syrupy", "syrupy",
"chewy", "chewy",
"medium-bodied", "medium-bodied",
@@ -14224,7 +14224,7 @@
"spirited", "spirited",
"well-integrated", "well-integrated",
"structured", "structured",
"rounded", "rounded-sm",
"luscious", "luscious",
"layered", "layered",
"viscous", "viscous",
@@ -14365,7 +14365,7 @@
"Creamy mouthfeel", "Creamy mouthfeel",
"Dense and rich", "Dense and rich",
"Syrupy sweetness balanced by oak", "Syrupy sweetness balanced by oak",
"Soft and rounded", "Soft and rounded-sm",
"Velvety tannins", "Velvety tannins",
"Warming spice prickle", "Warming spice prickle",
"Chewy and substantial", "Chewy and substantial",
@@ -14506,7 +14506,7 @@
"full-bodied", "full-bodied",
"rich", "rich",
"coating", "coating",
"rounded", "rounded-sm",
"dense", "dense",
"bold", "bold",
"thick", "thick",
@@ -14592,7 +14592,7 @@
"chewy", "chewy",
"rich", "rich",
"well-integrated", "well-integrated",
"rounded", "rounded-sm",
"luscious", "luscious",
"dense", "dense",
"polished", "polished",
@@ -14818,7 +14818,7 @@
"Luscious and succulent", "Luscious and succulent",
"Polished and well-integrated", "Polished and well-integrated",
"Silky with a subtle grip", "Silky with a subtle grip",
"Buttery and rounded", "Buttery and rounded-sm",
"Warm and enveloping", "Warm and enveloping",
"Concentrated and intense" "Concentrated and intense"
] ]
@@ -14870,7 +14870,7 @@
"Oiliness from pot still character", "Oiliness from pot still character",
"Creamy mouthfeel with soft oak grip", "Creamy mouthfeel with soft oak grip",
"Slightly viscous with tropical weight", "Slightly viscous with tropical weight",
"Polished and rounded tannins", "Polished and rounded-sm tannins",
"Bright but not sharp, approachable", "Bright but not sharp, approachable",
"Warming spice prickle", "Warming spice prickle",
"Creamy vanilla custard texture", "Creamy vanilla custard texture",
@@ -14931,7 +14931,7 @@
"polished oak texture", "polished oak texture",
"brine-tinged grip", "brine-tinged grip",
"cocoa-dusted smoothness", "cocoa-dusted smoothness",
"rounded yet angular spice", "rounded-sm yet angular spice",
"medium-to-full bodied", "medium-to-full bodied",
"lively pepper-prickly sensation" "lively pepper-prickly sensation"
] ]
@@ -15027,7 +15027,7 @@
"syrupy and viscous", "syrupy and viscous",
"silky and velvety", "silky and velvety",
"coating and mouth-coating", "coating and mouth-coating",
"rounded and plush", "rounded-sm and plush",
"spicy-prickly (white pepper)", "spicy-prickly (white pepper)",
"warming yet refreshing", "warming yet refreshing",
"balanced heat from virgin oak", "balanced heat from virgin oak",
@@ -15152,7 +15152,7 @@
"oily (coat the palate)", "oily (coat the palate)",
"slightly syrupy", "slightly syrupy",
"well-integrated alcohol", "well-integrated alcohol",
"rounded oak texture", "rounded-sm oak texture",
"polished tannin", "polished tannin",
"creamy (from vanilla/caramel)", "creamy (from vanilla/caramel)",
"bright acidity (wine-cask lift)" "bright acidity (wine-cask lift)"
@@ -15706,7 +15706,7 @@
], ],
"texture": [ "texture": [
"Silky and creamy mouthfeel", "Silky and creamy mouthfeel",
"Medium body with a rounded profile", "Medium body with a rounded-sm profile",
"Polished and gently coating", "Polished and gently coating",
"Juicy fruit sensation", "Juicy fruit sensation",
"Slightly waxy on the mid-palate", "Slightly waxy on the mid-palate",
@@ -15955,7 +15955,7 @@
"balanced dryness without harsh astringency" "balanced dryness without harsh astringency"
], ],
"texture": [ "texture": [
"silky and rounded mouthfeel", "silky and rounded-sm mouthfeel",
"medium body, neither oily nor watery", "medium body, neither oily nor watery",
"slightly waxy on the palate", "slightly waxy on the palate",
"creamy texture reminiscent of crème anglaise", "creamy texture reminiscent of crème anglaise",
@@ -16073,7 +16073,7 @@
"well-integrated alcohol", "well-integrated alcohol",
"slightly drying", "slightly drying",
"silky", "silky",
"rounded mouthfeel", "rounded-sm mouthfeel",
"soft and approachable", "soft and approachable",
"polished oak" "polished oak"
] ]
@@ -16133,7 +16133,7 @@
"texture": [ "texture": [
"silky", "silky",
"oily", "oily",
"rounded", "rounded-sm",
"well-balanced", "well-balanced",
"moderately creamy", "moderately creamy",
"polished", "polished",
@@ -16253,7 +16253,7 @@
"smooth and approachable", "smooth and approachable",
"concentrated and dense", "concentrated and dense",
"chalky-dry towards the end", "chalky-dry towards the end",
"rounded but structured", "rounded-sm but structured",
"supple with a citrusy cut", "supple with a citrusy cut",
"polished oak texture" "polished oak texture"
] ]
@@ -16310,11 +16310,11 @@
"full-bodied for low ABV", "full-bodied for low ABV",
"effervescent prickle", "effervescent prickle",
"sprightly zing", "sprightly zing",
"smooth and rounded", "smooth and rounded-sm",
"viscous syrup", "viscous syrup",
"well-integrated heat", "well-integrated heat",
"slightly waxy", "slightly waxy",
"rounded mouthfeel" "rounded-sm mouthfeel"
] ]
}, },
"Berry Bros & Rudd": { "Berry Bros & Rudd": {
@@ -16374,7 +16374,7 @@
"silky/velvety mid-palate", "silky/velvety mid-palate",
"medium-bodied and balanced", "medium-bodied and balanced",
"slightly oily with grip", "slightly oily with grip",
"crisp yet rounded", "crisp yet rounded-sm",
"chalky/mineral edge", "chalky/mineral edge",
"polished oak feel", "polished oak feel",
"creamy without being heavy", "creamy without being heavy",
@@ -16678,7 +16678,7 @@
"medium-bodied", "medium-bodied",
"slightly oily", "slightly oily",
"polished", "polished",
"rounded", "rounded-sm",
"well-integrated alcohol", "well-integrated alcohol",
"soft", "soft",
"crisp", "crisp",
@@ -17228,7 +17228,7 @@
"dense", "dense",
"oily-tear legs", "oily-tear legs",
"weighty", "weighty",
"rounded", "rounded-sm",
"polished", "polished",
"plush", "plush",
"satiny", "satiny",
@@ -17396,7 +17396,7 @@
"clean smoke / ember whisper" "clean smoke / ember whisper"
], ],
"texture": [ "texture": [
"smooth and rounded", "smooth and rounded-sm",
"silky / velvety", "silky / velvety",
"creamy (reminiscent of crème anglaise)", "creamy (reminiscent of crème anglaise)",
"medium-bodied and well-balanced", "medium-bodied and well-balanced",
@@ -17444,7 +17444,7 @@
"gentle peat (whisper)" "gentle peat (whisper)"
], ],
"finish": [ "finish": [
"smooth and rounded", "smooth and rounded-sm",
"lingering honey", "lingering honey",
"soft oak", "soft oak",
"dried fruit sultanas", "dried fruit sultanas",
@@ -17461,7 +17461,7 @@
"creamy", "creamy",
"well-balanced", "well-balanced",
"soft", "soft",
"rounded", "rounded-sm",
"slightly oily", "slightly oily",
"polished", "polished",
"approachable", "approachable",
@@ -17532,7 +17532,7 @@
], ],
"texture": [ "texture": [
"smooth and polished", "smooth and polished",
"creamy and rounded", "creamy and rounded-sm",
"medium-bodied and balanced", "medium-bodied and balanced",
"silky mouthfeel", "silky mouthfeel",
"well-integrated alcohol (no harshness)", "well-integrated alcohol (no harshness)",
@@ -17640,7 +17640,7 @@
"butterscotch" "butterscotch"
], ],
"finish": [ "finish": [
"smooth and rounded", "smooth and rounded-sm",
"medium length", "medium length",
"lingering honey and vanilla", "lingering honey and vanilla",
"sweet oak and gentle spice", "sweet oak and gentle spice",
@@ -17769,7 +17769,7 @@
"balanced sweetness and spice" "balanced sweetness and spice"
], ],
"finish": [ "finish": [
"smooth and rounded", "smooth and rounded-sm",
"lingering smokiness", "lingering smokiness",
"creamy vanilla", "creamy vanilla",
"oak-driven warmth", "oak-driven warmth",
@@ -17790,7 +17790,7 @@
"velvety", "velvety",
"silky", "silky",
"medium-bodied", "medium-bodied",
"rounded", "rounded-sm",
"oily", "oily",
"smooth", "smooth",
"soft", "soft",
@@ -17866,7 +17866,7 @@
"approachable", "approachable",
"smooth", "smooth",
"sprightly", "sprightly",
"rounded mouthfeel", "rounded-sm mouthfeel",
"gentle" "gentle"
] ]
}, },
@@ -17930,7 +17930,7 @@
"texture": [ "texture": [
"creamy", "creamy",
"oily", "oily",
"rounded", "rounded-sm",
"full-bodied", "full-bodied",
"silky", "silky",
"weighted", "weighted",
@@ -17989,7 +17989,7 @@
"creamy and velvety", "creamy and velvety",
"oily and coating", "oily and coating",
"waxy and beeswax-like", "waxy and beeswax-like",
"rounded and mellow", "rounded-sm and mellow",
"medium-bodied and balanced", "medium-bodied and balanced",
"silky and smooth", "silky and smooth",
"slightly drying oak", "slightly drying oak",

View File

@@ -5,7 +5,11 @@ const nextConfig = {
output: 'standalone', output: 'standalone',
// Enable source maps for Sentry stack traces in production // Enable source maps for Sentry stack traces in production
productionBrowserSourceMaps: !!process.env.GLITCHTIP_DSN, productionBrowserSourceMaps: !!process.env.GLITCHTIP_DSN,
// React Compiler for automatic memoization (React 19+)
reactCompiler: true,
experimental: { experimental: {
// Note: cacheComponents (PPR) disabled - requires Suspense boundaries for all auth contexts
// Can be enabled later after refactoring to RSC-first architecture
serverActions: { serverActions: {
bodySizeLimit: '10mb', bodySizeLimit: '10mb',
}, },

View File

@@ -44,6 +44,7 @@
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.57.0", "@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4.1.18",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1", "@testing-library/react": "^16.3.1",
"@types/node": "^20", "@types/node": "^20",
@@ -51,13 +52,13 @@
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.0.0",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.0.1", "babel-plugin-react-compiler": "^1.0.0",
"eslint": "^8", "eslint": "^8",
"eslint-config-next": "16.1.0", "eslint-config-next": "16.1.0",
"eslint-plugin-security": "^2.1.1", "eslint-plugin-security": "^2.1.1",
"jsdom": "^27.3.0", "jsdom": "^27.3.0",
"postcss": "^8", "postcss": "^8",
"tailwindcss": "^3.3.0", "tailwindcss": "^4.1.18",
"typescript": "^5", "typescript": "^5",
"vitest": "^4.0.16" "vitest": "^4.0.16"
}, },

588
pnpm-lock.yaml generated
View File

@@ -19,7 +19,7 @@ importers:
version: 1.11.0 version: 1.11.0
'@sentry/nextjs': '@sentry/nextjs':
specifier: ^10.34.0 specifier: ^10.34.0
version: 10.34.0(@opentelemetry/context-async-hooks@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(next@16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.104.1) version: 10.34.0(@opentelemetry/context-async-hooks@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(next@16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.104.1)
'@supabase/ssr': '@supabase/ssr':
specifier: ^0.5.2 specifier: ^0.5.2
version: 0.5.2(@supabase/supabase-js@2.88.0) version: 0.5.2(@supabase/supabase-js@2.88.0)
@@ -64,7 +64,7 @@ importers:
version: 5.1.6 version: 5.1.6
next: next:
specifier: 16.1.0 specifier: 16.1.0
version: 16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) version: 16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
openai: openai:
specifier: ^6.15.0 specifier: ^6.15.0
version: 6.15.0(ws@8.18.3)(zod@3.25.76) version: 6.15.0(ws@8.18.3)(zod@3.25.76)
@@ -99,6 +99,9 @@ importers:
'@playwright/test': '@playwright/test':
specifier: ^1.57.0 specifier: ^1.57.0
version: 1.57.0 version: 1.57.0
'@tailwindcss/postcss':
specifier: ^4.1.18
version: 4.1.18
'@testing-library/jest-dom': '@testing-library/jest-dom':
specifier: ^6.9.1 specifier: ^6.9.1
version: 6.9.1 version: 6.9.1
@@ -119,10 +122,10 @@ importers:
version: 10.0.0 version: 10.0.0
'@vitejs/plugin-react': '@vitejs/plugin-react':
specifier: ^5.1.2 specifier: ^5.1.2
version: 5.1.2(vite@7.3.0(@types/node@20.19.27)(jiti@1.21.7)(terser@5.46.0)) version: 5.1.2(vite@7.3.0(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0))
autoprefixer: babel-plugin-react-compiler:
specifier: ^10.0.1 specifier: ^1.0.0
version: 10.4.23(postcss@8.5.6) version: 1.0.0
eslint: eslint:
specifier: ^8 specifier: ^8
version: 8.57.1 version: 8.57.1
@@ -139,14 +142,14 @@ importers:
specifier: ^8 specifier: ^8
version: 8.5.6 version: 8.5.6
tailwindcss: tailwindcss:
specifier: ^3.3.0 specifier: ^4.1.18
version: 3.4.19 version: 4.1.18
typescript: typescript:
specifier: ^5 specifier: ^5
version: 5.9.3 version: 5.9.3
vitest: vitest:
specifier: ^4.0.16 specifier: ^4.0.16
version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(jiti@1.21.7)(jsdom@27.3.0)(terser@5.46.0) version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.46.0)
packages: packages:
@@ -1305,6 +1308,94 @@ packages:
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@tailwindcss/node@4.1.18':
resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==}
'@tailwindcss/oxide-android-arm64@4.1.18':
resolution: {integrity: sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [android]
'@tailwindcss/oxide-darwin-arm64@4.1.18':
resolution: {integrity: sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tailwindcss/oxide-darwin-x64@4.1.18':
resolution: {integrity: sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tailwindcss/oxide-freebsd-x64@4.1.18':
resolution: {integrity: sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [freebsd]
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
resolution: {integrity: sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
resolution: {integrity: sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
bundledDependencies:
- '@napi-rs/wasm-runtime'
- '@emnapi/core'
- '@emnapi/runtime'
- '@tybys/wasm-util'
- '@emnapi/wasi-threads'
- tslib
'@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
resolution: {integrity: sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tailwindcss/oxide-win32-x64-msvc@4.1.18':
resolution: {integrity: sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tailwindcss/oxide@4.1.18':
resolution: {integrity: sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==}
engines: {node: '>= 10'}
'@tailwindcss/postcss@4.1.18':
resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==}
'@tanstack/query-core@5.90.12': '@tanstack/query-core@5.90.12':
resolution: {integrity: sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==} resolution: {integrity: sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==}
@@ -1770,16 +1861,10 @@ packages:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'} engines: {node: '>=12'}
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
anymatch@3.1.3: anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
argparse@2.0.1: argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -1833,13 +1918,6 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
autoprefixer@10.4.23:
resolution: {integrity: sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA==}
engines: {node: ^10 || ^12 || >=14}
hasBin: true
peerDependencies:
postcss: ^8.1.0
available-typed-arrays@1.0.7: available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1860,6 +1938,9 @@ packages:
react-native-b4a: react-native-b4a:
optional: true optional: true
babel-plugin-react-compiler@1.0.0:
resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==}
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@@ -1961,10 +2042,6 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
camelcase-css@2.0.1:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
caniuse-lite@1.0.30001760: caniuse-lite@1.0.30001760:
resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==}
@@ -2017,10 +2094,6 @@ packages:
commander@2.20.3: commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
commondir@1.0.1: commondir@1.0.1:
resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
@@ -2045,11 +2118,6 @@ packages:
css.escape@1.5.1: css.escape@1.5.1:
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
hasBin: true
cssstyle@5.3.5: cssstyle@5.3.5:
resolution: {integrity: sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==} resolution: {integrity: sha512-GlsEptulso7Jg0VaOZ8BXQi3AkYM5BOJKEO/rjMidSCq70FkIC5y0eawrCXeYzxgt3OCf4Ls+eoxN+/05vN0Ag==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -2180,12 +2248,6 @@ packages:
dexie@4.2.1: dexie@4.2.1:
resolution: {integrity: sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==} resolution: {integrity: sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==}
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
doctrine@2.1.0: doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -2446,10 +2508,6 @@ packages:
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
engines: {node: '>=8.6.0'} engines: {node: '>=8.6.0'}
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
fast-json-stable-stringify@2.1.0: fast-json-stable-stringify@2.1.0:
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
@@ -2504,9 +2562,6 @@ packages:
forwarded-parse@2.1.2: forwarded-parse@2.1.2:
resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==}
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
framer-motion@12.23.26: framer-motion@12.23.26:
resolution: {integrity: sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==} resolution: {integrity: sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==}
peerDependencies: peerDependencies:
@@ -2878,8 +2933,8 @@ packages:
resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==}
engines: {node: '>= 10.13.0'} engines: {node: '>= 10.13.0'}
jiti@1.21.7: jiti@2.6.1:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true hasBin: true
js-tokens@4.0.0: js-tokens@4.0.0:
@@ -2948,12 +3003,75 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
lilconfig@3.1.3: lightningcss-android-arm64@1.30.2:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==}
engines: {node: '>=14'} engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
lines-and-columns@1.2.4: lightningcss-darwin-arm64@1.30.2:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.30.2:
resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.30.2:
resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.30.2:
resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.30.2:
resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
lightningcss-linux-arm64-musl@1.30.2:
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
lightningcss-linux-x64-gnu@1.30.2:
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
lightningcss-linux-x64-musl@1.30.2:
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
lightningcss-win32-arm64-msvc@1.30.2:
resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.30.2:
resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.30.2:
resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==}
engines: {node: '>= 12.0.0'}
loader-runner@4.3.1: loader-runner@4.3.1:
resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==} resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==}
@@ -3062,9 +3180,6 @@ packages:
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
nanoid@3.3.11: nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -3137,10 +3252,6 @@ packages:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
object-hash@3.0.0:
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
engines: {node: '>= 6'}
object-inspect@1.13.4: object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3274,14 +3385,6 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'} engines: {node: '>=12'}
pify@2.3.0:
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
engines: {node: '>=0.10.0'}
pirates@4.0.7:
resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==}
engines: {node: '>= 6'}
platform@1.3.6: platform@1.3.6:
resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==}
@@ -3299,49 +3402,6 @@ packages:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
postcss-import@15.1.0:
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
engines: {node: '>=14.0.0'}
peerDependencies:
postcss: ^8.0.0
postcss-js@4.1.0:
resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
engines: {node: ^12 || ^14 || >= 16}
peerDependencies:
postcss: ^8.4.21
postcss-load-config@6.0.1:
resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
engines: {node: '>= 18'}
peerDependencies:
jiti: '>=1.21.0'
postcss: '>=8.0.9'
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
jiti:
optional: true
postcss:
optional: true
tsx:
optional: true
yaml:
optional: true
postcss-nested@6.2.0:
resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
engines: {node: '>=12.0'}
peerDependencies:
postcss: ^8.2.14
postcss-selector-parser@6.1.2:
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
engines: {node: '>=4'}
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.4.31: postcss@8.4.31:
resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@@ -3449,9 +3509,6 @@ packages:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
read-cache@1.0.0:
resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==}
readable-stream@3.6.2: readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
@@ -3745,11 +3802,6 @@ packages:
babel-plugin-macros: babel-plugin-macros:
optional: true optional: true
sucrase@3.35.1:
resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==}
engines: {node: '>=16 || 14 >=14.17'}
hasBin: true
supports-color@7.2.0: supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -3765,10 +3817,8 @@ packages:
symbol-tree@3.2.4: symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
tailwindcss@3.4.19: tailwindcss@4.1.18:
resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==}
engines: {node: '>=14.0.0'}
hasBin: true
tapable@2.3.0: tapable@2.3.0:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
@@ -3820,13 +3870,6 @@ packages:
text-table@0.2.0: text-table@0.2.0:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
thenify-all@1.6.0:
resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==}
engines: {node: '>=0.8'}
thenify@3.3.1:
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
tiny-invariant@1.3.3: tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
@@ -3873,9 +3916,6 @@ packages:
peerDependencies: peerDependencies:
typescript: '>=4.8.4' typescript: '>=4.8.4'
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
tsconfig-paths@3.15.0: tsconfig-paths@3.15.0:
resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==}
@@ -5158,7 +5198,7 @@ snapshots:
'@sentry/core@10.34.0': {} '@sentry/core@10.34.0': {}
'@sentry/nextjs@10.34.0(@opentelemetry/context-async-hooks@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(next@16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.104.1)': '@sentry/nextjs@10.34.0(@opentelemetry/context-async-hooks@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.4.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.4.0(@opentelemetry/api@1.9.0))(next@16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)(webpack@5.104.1)':
dependencies: dependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@opentelemetry/semantic-conventions': 1.39.0 '@opentelemetry/semantic-conventions': 1.39.0
@@ -5171,7 +5211,7 @@ snapshots:
'@sentry/react': 10.34.0(react@19.2.3) '@sentry/react': 10.34.0(react@19.2.3)
'@sentry/vercel-edge': 10.34.0 '@sentry/vercel-edge': 10.34.0
'@sentry/webpack-plugin': 4.6.2(webpack@5.104.1) '@sentry/webpack-plugin': 4.6.2(webpack@5.104.1)
next: 16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) next: 16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
rollup: 4.53.5 rollup: 4.53.5
stacktrace-parser: 0.1.11 stacktrace-parser: 0.1.11
transitivePeerDependencies: transitivePeerDependencies:
@@ -5322,6 +5362,75 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@tailwindcss/node@4.1.18':
dependencies:
'@jridgewell/remapping': 2.3.5
enhanced-resolve: 5.18.4
jiti: 2.6.1
lightningcss: 1.30.2
magic-string: 0.30.21
source-map-js: 1.2.1
tailwindcss: 4.1.18
'@tailwindcss/oxide-android-arm64@4.1.18':
optional: true
'@tailwindcss/oxide-darwin-arm64@4.1.18':
optional: true
'@tailwindcss/oxide-darwin-x64@4.1.18':
optional: true
'@tailwindcss/oxide-freebsd-x64@4.1.18':
optional: true
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18':
optional: true
'@tailwindcss/oxide-linux-arm64-gnu@4.1.18':
optional: true
'@tailwindcss/oxide-linux-arm64-musl@4.1.18':
optional: true
'@tailwindcss/oxide-linux-x64-gnu@4.1.18':
optional: true
'@tailwindcss/oxide-linux-x64-musl@4.1.18':
optional: true
'@tailwindcss/oxide-wasm32-wasi@4.1.18':
optional: true
'@tailwindcss/oxide-win32-arm64-msvc@4.1.18':
optional: true
'@tailwindcss/oxide-win32-x64-msvc@4.1.18':
optional: true
'@tailwindcss/oxide@4.1.18':
optionalDependencies:
'@tailwindcss/oxide-android-arm64': 4.1.18
'@tailwindcss/oxide-darwin-arm64': 4.1.18
'@tailwindcss/oxide-darwin-x64': 4.1.18
'@tailwindcss/oxide-freebsd-x64': 4.1.18
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.18
'@tailwindcss/oxide-linux-arm64-gnu': 4.1.18
'@tailwindcss/oxide-linux-arm64-musl': 4.1.18
'@tailwindcss/oxide-linux-x64-gnu': 4.1.18
'@tailwindcss/oxide-linux-x64-musl': 4.1.18
'@tailwindcss/oxide-wasm32-wasi': 4.1.18
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.18
'@tailwindcss/oxide-win32-x64-msvc': 4.1.18
'@tailwindcss/postcss@4.1.18':
dependencies:
'@alloc/quick-lru': 5.2.0
'@tailwindcss/node': 4.1.18
'@tailwindcss/oxide': 4.1.18
postcss: 8.5.6
tailwindcss: 4.1.18
'@tanstack/query-core@5.90.12': {} '@tanstack/query-core@5.90.12': {}
'@tanstack/react-query@5.90.12(react@19.2.3)': '@tanstack/react-query@5.90.12(react@19.2.3)':
@@ -5636,7 +5745,7 @@ snapshots:
'@vercel/oidc@3.0.5': {} '@vercel/oidc@3.0.5': {}
'@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@20.19.27)(jiti@1.21.7)(terser@5.46.0))': '@vitejs/plugin-react@5.1.2(vite@7.3.0(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0))':
dependencies: dependencies:
'@babel/core': 7.28.5 '@babel/core': 7.28.5
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5)
@@ -5644,7 +5753,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.53 '@rolldown/pluginutils': 1.0.0-beta.53
'@types/babel__core': 7.20.5 '@types/babel__core': 7.20.5
react-refresh: 0.18.0 react-refresh: 0.18.0
vite: 7.3.0(@types/node@20.19.27)(jiti@1.21.7)(terser@5.46.0) vite: 7.3.0(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -5657,13 +5766,13 @@ snapshots:
chai: 6.2.1 chai: 6.2.1
tinyrainbow: 3.0.3 tinyrainbow: 3.0.3
'@vitest/mocker@4.0.16(vite@7.3.0(@types/node@20.19.27)(jiti@1.21.7)(terser@5.46.0))': '@vitest/mocker@4.0.16(vite@7.3.0(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0))':
dependencies: dependencies:
'@vitest/spy': 4.0.16 '@vitest/spy': 4.0.16
estree-walker: 3.0.3 estree-walker: 3.0.3
magic-string: 0.30.21 magic-string: 0.30.21
optionalDependencies: optionalDependencies:
vite: 7.3.0(@types/node@20.19.27)(jiti@1.21.7)(terser@5.46.0) vite: 7.3.0(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)
'@vitest/pretty-format@4.0.16': '@vitest/pretty-format@4.0.16':
dependencies: dependencies:
@@ -5844,15 +5953,11 @@ snapshots:
ansi-styles@6.2.3: {} ansi-styles@6.2.3: {}
any-promise@1.3.0: {}
anymatch@3.1.3: anymatch@3.1.3:
dependencies: dependencies:
normalize-path: 3.0.0 normalize-path: 3.0.0
picomatch: 2.3.1 picomatch: 2.3.1
arg@5.0.2: {}
argparse@2.0.1: {} argparse@2.0.1: {}
aria-query@5.3.0: aria-query@5.3.0:
@@ -5934,15 +6039,6 @@ snapshots:
async-function@1.0.0: {} async-function@1.0.0: {}
autoprefixer@10.4.23(postcss@8.5.6):
dependencies:
browserslist: 4.28.1
caniuse-lite: 1.0.30001760
fraction.js: 5.3.4
picocolors: 1.1.1
postcss: 8.5.6
postcss-value-parser: 4.2.0
available-typed-arrays@1.0.7: available-typed-arrays@1.0.7:
dependencies: dependencies:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
@@ -5953,6 +6049,10 @@ snapshots:
b4a@1.7.3: {} b4a@1.7.3: {}
babel-plugin-react-compiler@1.0.0:
dependencies:
'@babel/types': 7.28.5
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
bare-events@2.8.2: {} bare-events@2.8.2: {}
@@ -6061,8 +6161,6 @@ snapshots:
callsites@3.1.0: {} callsites@3.1.0: {}
camelcase-css@2.0.1: {}
caniuse-lite@1.0.30001760: {} caniuse-lite@1.0.30001760: {}
canvas-confetti@1.9.4: {} canvas-confetti@1.9.4: {}
@@ -6114,8 +6212,6 @@ snapshots:
commander@2.20.3: {} commander@2.20.3: {}
commander@4.1.1: {}
commondir@1.0.1: {} commondir@1.0.1: {}
concat-map@0.0.1: {} concat-map@0.0.1: {}
@@ -6137,8 +6233,6 @@ snapshots:
css.escape@1.5.1: {} css.escape@1.5.1: {}
cssesc@3.0.0: {}
cssstyle@5.3.5: cssstyle@5.3.5:
dependencies: dependencies:
'@asamuzakjp/css-color': 4.1.1 '@asamuzakjp/css-color': 4.1.1
@@ -6254,10 +6348,6 @@ snapshots:
dexie@4.2.1: {} dexie@4.2.1: {}
didyoumean@1.2.2: {}
dlv@1.1.3: {}
doctrine@2.1.0: doctrine@2.1.0:
dependencies: dependencies:
esutils: 2.0.3 esutils: 2.0.3
@@ -6687,14 +6777,6 @@ snapshots:
merge2: 1.4.1 merge2: 1.4.1
micromatch: 4.0.8 micromatch: 4.0.8
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
'@nodelib/fs.walk': 1.2.8
glob-parent: 5.1.2
merge2: 1.4.1
micromatch: 4.0.8
fast-json-stable-stringify@2.1.0: {} fast-json-stable-stringify@2.1.0: {}
fast-levenshtein@2.0.6: {} fast-levenshtein@2.0.6: {}
@@ -6743,8 +6825,6 @@ snapshots:
forwarded-parse@2.1.2: {} forwarded-parse@2.1.2: {}
fraction.js@5.3.4: {}
framer-motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3): framer-motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies: dependencies:
motion-dom: 12.23.23 motion-dom: 12.23.23
@@ -7121,7 +7201,7 @@ snapshots:
merge-stream: 2.0.0 merge-stream: 2.0.0
supports-color: 8.1.1 supports-color: 8.1.1
jiti@1.21.7: {} jiti@2.6.1: {}
js-tokens@4.0.0: {} js-tokens@4.0.0: {}
@@ -7198,9 +7278,54 @@ snapshots:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
type-check: 0.4.0 type-check: 0.4.0
lilconfig@3.1.3: {} lightningcss-android-arm64@1.30.2:
optional: true
lines-and-columns@1.2.4: {} lightningcss-darwin-arm64@1.30.2:
optional: true
lightningcss-darwin-x64@1.30.2:
optional: true
lightningcss-freebsd-x64@1.30.2:
optional: true
lightningcss-linux-arm-gnueabihf@1.30.2:
optional: true
lightningcss-linux-arm64-gnu@1.30.2:
optional: true
lightningcss-linux-arm64-musl@1.30.2:
optional: true
lightningcss-linux-x64-gnu@1.30.2:
optional: true
lightningcss-linux-x64-musl@1.30.2:
optional: true
lightningcss-win32-arm64-msvc@1.30.2:
optional: true
lightningcss-win32-x64-msvc@1.30.2:
optional: true
lightningcss@1.30.2:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
lightningcss-android-arm64: 1.30.2
lightningcss-darwin-arm64: 1.30.2
lightningcss-darwin-x64: 1.30.2
lightningcss-freebsd-x64: 1.30.2
lightningcss-linux-arm-gnueabihf: 1.30.2
lightningcss-linux-arm64-gnu: 1.30.2
lightningcss-linux-arm64-musl: 1.30.2
lightningcss-linux-x64-gnu: 1.30.2
lightningcss-linux-x64-musl: 1.30.2
lightningcss-win32-arm64-msvc: 1.30.2
lightningcss-win32-x64-msvc: 1.30.2
loader-runner@4.3.1: {} loader-runner@4.3.1: {}
@@ -7285,12 +7410,6 @@ snapshots:
ms@2.1.3: {} ms@2.1.3: {}
mz@2.7.0:
dependencies:
any-promise: 1.3.0
object-assign: 4.1.1
thenify-all: 1.6.0
nanoid@3.3.11: {} nanoid@3.3.11: {}
nanoid@5.1.6: {} nanoid@5.1.6: {}
@@ -7303,7 +7422,7 @@ snapshots:
neo-async@2.6.2: {} neo-async@2.6.2: {}
next@16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): next@16.1.0(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(@playwright/test@1.57.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies: dependencies:
'@next/env': 16.1.0 '@next/env': 16.1.0
'@swc/helpers': 0.5.15 '@swc/helpers': 0.5.15
@@ -7324,6 +7443,7 @@ snapshots:
'@next/swc-win32-x64-msvc': 16.1.0 '@next/swc-win32-x64-msvc': 16.1.0
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0
'@playwright/test': 1.57.0 '@playwright/test': 1.57.0
babel-plugin-react-compiler: 1.0.0
sharp: 0.34.5 sharp: 0.34.5
transitivePeerDependencies: transitivePeerDependencies:
- '@babel/core' - '@babel/core'
@@ -7345,8 +7465,6 @@ snapshots:
object-assign@4.1.1: {} object-assign@4.1.1: {}
object-hash@3.0.0: {}
object-inspect@1.13.4: {} object-inspect@1.13.4: {}
object-keys@1.1.1: {} object-keys@1.1.1: {}
@@ -7486,10 +7604,6 @@ snapshots:
picomatch@4.0.3: {} picomatch@4.0.3: {}
pify@2.3.0: {}
pirates@4.0.7: {}
platform@1.3.6: {} platform@1.3.6: {}
playwright-core@1.57.0: {} playwright-core@1.57.0: {}
@@ -7502,37 +7616,6 @@ snapshots:
possible-typed-array-names@1.1.0: {} possible-typed-array-names@1.1.0: {}
postcss-import@15.1.0(postcss@8.5.6):
dependencies:
postcss: 8.5.6
postcss-value-parser: 4.2.0
read-cache: 1.0.0
resolve: 1.22.11
postcss-js@4.1.0(postcss@8.5.6):
dependencies:
camelcase-css: 2.0.1
postcss: 8.5.6
postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.6):
dependencies:
lilconfig: 3.1.3
optionalDependencies:
jiti: 1.21.7
postcss: 8.5.6
postcss-nested@6.2.0(postcss@8.5.6):
dependencies:
postcss: 8.5.6
postcss-selector-parser: 6.1.2
postcss-selector-parser@6.1.2:
dependencies:
cssesc: 3.0.0
util-deprecate: 1.0.2
postcss-value-parser@4.2.0: {}
postcss@8.4.31: postcss@8.4.31:
dependencies: dependencies:
nanoid: 3.3.11 nanoid: 3.3.11
@@ -7654,10 +7737,6 @@ snapshots:
react@19.2.3: {} react@19.2.3: {}
read-cache@1.0.0:
dependencies:
pify: 2.3.0
readable-stream@3.6.2: readable-stream@3.6.2:
dependencies: dependencies:
inherits: 2.0.4 inherits: 2.0.4
@@ -8078,16 +8157,6 @@ snapshots:
optionalDependencies: optionalDependencies:
'@babel/core': 7.28.5 '@babel/core': 7.28.5
sucrase@3.35.1:
dependencies:
'@jridgewell/gen-mapping': 0.3.13
commander: 4.1.1
lines-and-columns: 1.2.4
mz: 2.7.0
pirates: 4.0.7
tinyglobby: 0.2.15
ts-interface-checker: 0.1.13
supports-color@7.2.0: supports-color@7.2.0:
dependencies: dependencies:
has-flag: 4.0.0 has-flag: 4.0.0
@@ -8100,33 +8169,7 @@ snapshots:
symbol-tree@3.2.4: {} symbol-tree@3.2.4: {}
tailwindcss@3.4.19: tailwindcss@4.1.18: {}
dependencies:
'@alloc/quick-lru': 5.2.0
arg: 5.0.2
chokidar: 3.6.0
didyoumean: 1.2.2
dlv: 1.1.3
fast-glob: 3.3.3
glob-parent: 6.0.2
is-glob: 4.0.3
jiti: 1.21.7
lilconfig: 3.1.3
micromatch: 4.0.8
normalize-path: 3.0.0
object-hash: 3.0.0
picocolors: 1.1.1
postcss: 8.5.6
postcss-import: 15.1.0(postcss@8.5.6)
postcss-js: 4.1.0(postcss@8.5.6)
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.6)
postcss-nested: 6.2.0(postcss@8.5.6)
postcss-selector-parser: 6.1.2
resolve: 1.22.11
sucrase: 3.35.1
transitivePeerDependencies:
- tsx
- yaml
tapable@2.3.0: {} tapable@2.3.0: {}
@@ -8206,14 +8249,6 @@ snapshots:
text-table@0.2.0: {} text-table@0.2.0: {}
thenify-all@1.6.0:
dependencies:
thenify: 3.3.1
thenify@3.3.1:
dependencies:
any-promise: 1.3.0
tiny-invariant@1.3.3: {} tiny-invariant@1.3.3: {}
tinybench@2.9.0: {} tinybench@2.9.0: {}
@@ -8251,8 +8286,6 @@ snapshots:
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3
ts-interface-checker@0.1.13: {}
tsconfig-paths@3.15.0: tsconfig-paths@3.15.0:
dependencies: dependencies:
'@types/json5': 0.0.29 '@types/json5': 0.0.29
@@ -8399,7 +8432,7 @@ snapshots:
d3-time: 3.1.0 d3-time: 3.1.0
d3-timer: 3.0.1 d3-timer: 3.0.1
vite@7.3.0(@types/node@20.19.27)(jiti@1.21.7)(terser@5.46.0): vite@7.3.0(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0):
dependencies: dependencies:
esbuild: 0.27.2 esbuild: 0.27.2
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
@@ -8410,13 +8443,14 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/node': 20.19.27 '@types/node': 20.19.27
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 1.21.7 jiti: 2.6.1
lightningcss: 1.30.2
terser: 5.46.0 terser: 5.46.0
vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(jiti@1.21.7)(jsdom@27.3.0)(terser@5.46.0): vitest@4.0.16(@opentelemetry/api@1.9.0)(@types/node@20.19.27)(jiti@2.6.1)(jsdom@27.3.0)(lightningcss@1.30.2)(terser@5.46.0):
dependencies: dependencies:
'@vitest/expect': 4.0.16 '@vitest/expect': 4.0.16
'@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@20.19.27)(jiti@1.21.7)(terser@5.46.0)) '@vitest/mocker': 4.0.16(vite@7.3.0(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0))
'@vitest/pretty-format': 4.0.16 '@vitest/pretty-format': 4.0.16
'@vitest/runner': 4.0.16 '@vitest/runner': 4.0.16
'@vitest/snapshot': 4.0.16 '@vitest/snapshot': 4.0.16
@@ -8433,7 +8467,7 @@ snapshots:
tinyexec: 1.0.2 tinyexec: 1.0.2
tinyglobby: 0.2.15 tinyglobby: 0.2.15
tinyrainbow: 3.0.3 tinyrainbow: 3.0.3
vite: 7.3.0(@types/node@20.19.27)(jiti@1.21.7)(terser@5.46.0) vite: 7.3.0(@types/node@20.19.27)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)
why-is-node-running: 2.3.0 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@opentelemetry/api': 1.9.0 '@opentelemetry/api': 1.9.0

View File

@@ -1,6 +1,5 @@
module.exports = { module.exports = {
plugins: { plugins: {
tailwindcss: {}, '@tailwindcss/postcss': {},
autoprefixer: {},
}, },
}; };

View File

@@ -162,7 +162,7 @@ export default function BannerManager({ initialBanners }: BannerManagerProps) {
value={formData.title} value={formData.title}
onChange={e => setFormData({ ...formData, title: e.target.value })} onChange={e => setFormData({ ...formData, title: e.target.value })}
placeholder="Banner title" placeholder="Banner title"
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-none focus:border-orange-600" className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
required required
/> />
</div> </div>
@@ -174,7 +174,7 @@ export default function BannerManager({ initialBanners }: BannerManagerProps) {
value={formData.image_url} value={formData.image_url}
onChange={e => setFormData({ ...formData, image_url: e.target.value })} onChange={e => setFormData({ ...formData, image_url: e.target.value })}
placeholder="https://example.com/banner.jpg" placeholder="https://example.com/banner.jpg"
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-none focus:border-orange-600" className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
required required
/> />
</div> </div>
@@ -187,7 +187,7 @@ export default function BannerManager({ initialBanners }: BannerManagerProps) {
value={formData.link_target} value={formData.link_target}
onChange={e => setFormData({ ...formData, link_target: e.target.value })} onChange={e => setFormData({ ...formData, link_target: e.target.value })}
placeholder="/sessions" placeholder="/sessions"
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-none focus:border-orange-600" className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
/> />
</div> </div>
<div> <div>
@@ -197,7 +197,7 @@ export default function BannerManager({ initialBanners }: BannerManagerProps) {
value={formData.cta_text} value={formData.cta_text}
onChange={e => setFormData({ ...formData, cta_text: e.target.value })} onChange={e => setFormData({ ...formData, cta_text: e.target.value })}
placeholder="Open" placeholder="Open"
className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-none focus:border-orange-600" className="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
/> />
</div> </div>
</div> </div>
@@ -320,7 +320,7 @@ export default function BannerManager({ initialBanners }: BannerManagerProps) {
/* Display Mode */ /* Display Mode */
<div className="flex gap-4"> <div className="flex gap-4">
{/* Thumbnail */} {/* Thumbnail */}
<div className="w-32 h-20 rounded-lg overflow-hidden bg-zinc-800 flex-shrink-0"> <div className="w-32 h-20 rounded-lg overflow-hidden bg-zinc-800 shrink-0">
<img <img
src={banner.image_url} src={banner.image_url}
alt={banner.title} alt={banner.title}

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';

View File

@@ -123,7 +123,7 @@ export default function AdminBottlesList({ bottles }: AdminBottlesListProps) {
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
placeholder="Search bottles, distilleries, or users..." placeholder="Search bottles, distilleries, or users..."
className="w-full pl-12 pr-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-none focus:border-orange-600" className="w-full pl-12 pr-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
/> />
</div> </div>
@@ -133,7 +133,7 @@ export default function AdminBottlesList({ bottles }: AdminBottlesListProps) {
<select <select
value={filterUser || ''} value={filterUser || ''}
onChange={e => setFilterUser(e.target.value || null)} onChange={e => setFilterUser(e.target.value || null)}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-none focus:border-orange-600 appearance-none cursor-pointer" className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden focus:border-orange-600 appearance-none cursor-pointer"
> >
<option value="">All Users</option> <option value="">All Users</option>
{users.map(([id, name]) => ( {users.map(([id, name]) => (
@@ -145,7 +145,7 @@ export default function AdminBottlesList({ bottles }: AdminBottlesListProps) {
<select <select
value={filterCategory || ''} value={filterCategory || ''}
onChange={e => setFilterCategory(e.target.value || null)} onChange={e => setFilterCategory(e.target.value || null)}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-none focus:border-orange-600 appearance-none cursor-pointer" className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden focus:border-orange-600 appearance-none cursor-pointer"
> >
<option value="">All Categories</option> <option value="">All Categories</option>
{categories.map(cat => ( {categories.map(cat => (
@@ -161,7 +161,7 @@ export default function AdminBottlesList({ bottles }: AdminBottlesListProps) {
setSortBy(by); setSortBy(by);
setSortOrder(order); setSortOrder(order);
}} }}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-none focus:border-orange-600 appearance-none cursor-pointer" className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden focus:border-orange-600 appearance-none cursor-pointer"
> >
<option value="created_at-desc">Newest First</option> <option value="created_at-desc">Newest First</option>
<option value="created_at-asc">Oldest First</option> <option value="created_at-asc">Oldest First</option>
@@ -202,7 +202,7 @@ export default function AdminBottlesList({ bottles }: AdminBottlesListProps) {
className="bg-zinc-900 rounded-2xl border border-zinc-800 overflow-hidden hover:border-zinc-700 transition-colors" className="bg-zinc-900 rounded-2xl border border-zinc-800 overflow-hidden hover:border-zinc-700 transition-colors"
> >
{/* Image */} {/* Image */}
<div className="aspect-[4/3] relative bg-zinc-800"> <div className="aspect-4/3 relative bg-zinc-800">
{bottle.image_url ? ( {bottle.image_url ? (
<img <img
src={bottle.image_url} src={bottle.image_url}
@@ -217,14 +217,14 @@ export default function AdminBottlesList({ bottles }: AdminBottlesListProps) {
{/* Category Badge */} {/* Category Badge */}
{bottle.category && ( {bottle.category && (
<span className="absolute top-2 left-2 px-2 py-1 bg-black/60 backdrop-blur-sm text-[10px] font-bold text-white rounded-lg uppercase"> <span className="absolute top-2 left-2 px-2 py-1 bg-black/60 backdrop-blur-xs text-[10px] font-bold text-white rounded-lg uppercase">
{bottle.category} {bottle.category}
</span> </span>
)} )}
{/* Rating Badge */} {/* Rating Badge */}
{avgRating > 0 && ( {avgRating > 0 && (
<span className="absolute top-2 right-2 px-2 py-1 bg-orange-600/90 backdrop-blur-sm text-xs font-bold text-white rounded-lg flex items-center gap-1"> <span className="absolute top-2 right-2 px-2 py-1 bg-orange-600/90 backdrop-blur-xs text-xs font-bold text-white rounded-lg flex items-center gap-1">
<Star size={12} fill="currentColor" /> <Star size={12} fill="currentColor" />
{avgRating.toFixed(1)} {avgRating.toFixed(1)}
</span> </span>

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { checkIsAdmin } from '@/services/track-api-usage'; import { checkIsAdmin } from '@/services/track-api-usage';
@@ -49,7 +48,7 @@ export default async function OcrLogsPage() {
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg"> <div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<Camera size={20} className="text-blue-600 dark:text-blue-400" /> <Camera size={20} className="text-blue-600 dark:text-blue-400" />
@@ -60,7 +59,7 @@ export default async function OcrLogsPage() {
<div className="text-xs text-zinc-500 mt-1">All time</div> <div className="text-xs text-zinc-500 mt-1">All time</div>
</div> </div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg"> <div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
<Calendar size={20} className="text-green-600 dark:text-green-400" /> <Calendar size={20} className="text-green-600 dark:text-green-400" />
@@ -71,7 +70,7 @@ export default async function OcrLogsPage() {
<div className="text-xs text-zinc-500 mt-1">Scans today</div> <div className="text-xs text-zinc-500 mt-1">Scans today</div>
</div> </div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg"> <div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<Percent size={20} className="text-amber-600 dark:text-amber-400" /> <Percent size={20} className="text-amber-600 dark:text-amber-400" />
@@ -82,7 +81,7 @@ export default async function OcrLogsPage() {
<div className="text-xs text-zinc-500 mt-1">Recognition quality</div> <div className="text-xs text-zinc-500 mt-1">Recognition quality</div>
</div> </div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg"> <div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<TrendingUp size={20} className="text-purple-600 dark:text-purple-400" /> <TrendingUp size={20} className="text-purple-600 dark:text-purple-400" />
@@ -100,7 +99,7 @@ export default async function OcrLogsPage() {
{/* Top Distilleries */} {/* Top Distilleries */}
{stats.topDistilleries.length > 0 && ( {stats.topDistilleries.length > 0 && (
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Most Scanned Distilleries</h2> <h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Most Scanned Distilleries</h2>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{stats.topDistilleries.map((d, i) => ( {stats.topDistilleries.map((d, i) => (
@@ -119,7 +118,7 @@ export default async function OcrLogsPage() {
)} )}
{/* OCR Logs Grid */} {/* OCR Logs Grid */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Recent OCR Scans</h2> <h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Recent OCR Scans</h2>
{logs.length === 0 ? ( {logs.length === 0 ? (
@@ -136,7 +135,7 @@ export default async function OcrLogsPage() {
className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-4 border border-zinc-200 dark:border-zinc-700 hover:border-orange-500/50 transition-colors" className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-4 border border-zinc-200 dark:border-zinc-700 hover:border-orange-500/50 transition-colors"
> >
{/* Image Preview */} {/* Image Preview */}
<div className="relative aspect-[4/3] rounded-lg overflow-hidden bg-zinc-200 dark:bg-zinc-700 mb-3"> <div className="relative aspect-4/3 rounded-lg overflow-hidden bg-zinc-200 dark:bg-zinc-700 mb-3">
{log.image_thumbnail ? ( {log.image_thumbnail ? (
<img <img
src={log.image_thumbnail} src={log.image_thumbnail}
@@ -175,7 +174,7 @@ export default async function OcrLogsPage() {
{log.distillery} {log.distillery}
</span> </span>
{log.distillery_source && ( {log.distillery_source && (
<span className="text-[10px] px-1.5 py-0.5 bg-zinc-200 dark:bg-zinc-700 rounded text-zinc-500"> <span className="text-[10px] px-1.5 py-0.5 bg-zinc-200 dark:bg-zinc-700 rounded-sm text-zinc-500">
{log.distillery_source} {log.distillery_source}
</span> </span>
)} )}
@@ -190,22 +189,22 @@ export default async function OcrLogsPage() {
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{log.abv && ( {log.abv && (
<span className="px-2 py-0.5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded text-[10px] font-bold"> <span className="px-2 py-0.5 bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400 rounded-sm text-[10px] font-bold">
{log.abv}% {log.abv}%
</span> </span>
)} )}
{log.age && ( {log.age && (
<span className="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded text-[10px] font-bold"> <span className="px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded-sm text-[10px] font-bold">
{log.age}y {log.age}y
</span> </span>
)} )}
{log.vintage && ( {log.vintage && (
<span className="px-2 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded text-[10px] font-bold"> <span className="px-2 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded-sm text-[10px] font-bold">
{log.vintage} {log.vintage}
</span> </span>
)} )}
{log.volume && ( {log.volume && (
<span className="px-2 py-0.5 bg-zinc-100 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400 rounded text-[10px] font-bold"> <span className="px-2 py-0.5 bg-zinc-100 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400 rounded-sm text-[10px] font-bold">
{log.volume} {log.volume}
</span> </span>
)} )}
@@ -218,7 +217,7 @@ export default async function OcrLogsPage() {
<summary className="text-[10px] font-bold text-zinc-400 cursor-pointer hover:text-orange-500 uppercase"> <summary className="text-[10px] font-bold text-zinc-400 cursor-pointer hover:text-orange-500 uppercase">
Raw Text Raw Text
</summary> </summary>
<pre className="mt-2 p-2 bg-zinc-100 dark:bg-zinc-900 rounded text-[9px] text-zinc-500 overflow-x-auto max-h-20 whitespace-pre-wrap"> <pre className="mt-2 p-2 bg-zinc-100 dark:bg-zinc-900 rounded-sm text-[9px] text-zinc-500 overflow-x-auto max-h-20 whitespace-pre-wrap">
{log.raw_text} {log.raw_text}
</pre> </pre>
</details> </details>

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { checkIsAdmin, getGlobalApiStats } from '@/services/track-api-usage'; import { checkIsAdmin, getGlobalApiStats } from '@/services/track-api-usage';
@@ -158,7 +157,7 @@ export default async function AdminPage() {
{/* Global Stats Cards */} {/* Global Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg"> <div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<BarChart3 size={20} className="text-blue-600 dark:text-blue-400" /> <BarChart3 size={20} className="text-blue-600 dark:text-blue-400" />
@@ -169,7 +168,7 @@ export default async function AdminPage() {
<div className="text-xs text-zinc-500 mt-1">All time</div> <div className="text-xs text-zinc-500 mt-1">All time</div>
</div> </div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg"> <div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
<Calendar size={20} className="text-green-600 dark:text-green-400" /> <Calendar size={20} className="text-green-600 dark:text-green-400" />
@@ -188,7 +187,7 @@ export default async function AdminPage() {
</div> </div>
</div> </div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg"> <div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<TrendingUp size={20} className="text-amber-600 dark:text-amber-400" /> <TrendingUp size={20} className="text-amber-600 dark:text-amber-400" />
@@ -199,7 +198,7 @@ export default async function AdminPage() {
<div className="text-xs text-zinc-500 mt-1">Whiskybase searches</div> <div className="text-xs text-zinc-500 mt-1">Whiskybase searches</div>
</div> </div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg"> <div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<Users size={20} className="text-purple-600 dark:text-purple-400" /> <Users size={20} className="text-purple-600 dark:text-purple-400" />
@@ -212,7 +211,7 @@ export default async function AdminPage() {
</div> </div>
{/* Top Users */} {/* Top Users */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Top Users by API Usage</h2> <h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Top Users by API Usage</h2>
<div className="space-y-3"> <div className="space-y-3">
{topUsersWithStats.map((user, index) => ( {topUsersWithStats.map((user, index) => (
@@ -235,7 +234,7 @@ export default async function AdminPage() {
</div> </div>
{/* Recent API Calls */} {/* Recent API Calls */}
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Recent API Calls</h2> <h2 className="text-xl font-black text-zinc-900 dark:text-white mb-4">Recent API Calls</h2>
<div className="text-sm text-zinc-500 mb-4"> <div className="text-sm text-zinc-500 mb-4">
Total calls logged: {recentUsage?.length || 0} Total calls logged: {recentUsage?.length || 0}
@@ -296,7 +295,7 @@ export default async function AdminPage() {
{call.response_text && ( {call.response_text && (
<details className="text-[10px]"> <details className="text-[10px]">
<summary className="cursor-pointer text-orange-600 hover:text-orange-700 font-bold uppercase transition-colors">Response</summary> <summary className="cursor-pointer text-orange-600 hover:text-orange-700 font-bold uppercase transition-colors">Response</summary>
<pre className="mt-2 p-2 bg-zinc-100 dark:bg-zinc-950 rounded border border-zinc-200 dark:border-zinc-800 overflow-x-auto max-w-sm whitespace-pre-wrap font-mono text-[9px] text-zinc-400"> <pre className="mt-2 p-2 bg-zinc-100 dark:bg-zinc-950 rounded-sm border border-zinc-200 dark:border-zinc-800 overflow-x-auto max-w-sm whitespace-pre-wrap font-mono text-[9px] text-zinc-400">
{call.response_text} {call.response_text}
</pre> </pre>
</details> </details>
@@ -310,7 +309,7 @@ export default async function AdminPage() {
<div className="group relative"> <div className="group relative">
<span className="text-red-600 dark:text-red-400 font-black text-xs cursor-help">ERR</span> <span className="text-red-600 dark:text-red-400 font-black text-xs cursor-help">ERR</span>
{call.error_message && ( {call.error_message && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-48 p-2 bg-red-600 text-white text-[9px] rounded shadow-xl opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50"> <div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-48 p-2 bg-red-600 text-white text-[9px] rounded-sm shadow-xl opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-50">
{call.error_message} {call.error_message}
</div> </div>
)} )}

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { checkIsAdmin } from '@/services/track-api-usage'; import { checkIsAdmin } from '@/services/track-api-usage';

View File

@@ -93,7 +93,7 @@ export default function AdminSessionsList({ sessions }: AdminSessionsListProps)
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
placeholder="Search sessions or hosts..." placeholder="Search sessions or hosts..."
className="w-full pl-12 pr-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-none focus:border-orange-600" className="w-full pl-12 pr-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
/> />
</div> </div>
@@ -101,7 +101,7 @@ export default function AdminSessionsList({ sessions }: AdminSessionsListProps)
<select <select
value={filterHost || ''} value={filterHost || ''}
onChange={e => setFilterHost(e.target.value || null)} onChange={e => setFilterHost(e.target.value || null)}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-none" className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden"
> >
<option value="">All Hosts</option> <option value="">All Hosts</option>
{hosts.map(([id, name]) => ( {hosts.map(([id, name]) => (
@@ -112,7 +112,7 @@ export default function AdminSessionsList({ sessions }: AdminSessionsListProps)
<select <select
value={filterStatus} value={filterStatus}
onChange={e => setFilterStatus(e.target.value as any)} onChange={e => setFilterStatus(e.target.value as any)}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-none" className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden"
> >
<option value="all">All Status</option> <option value="all">All Status</option>
<option value="active">Active</option> <option value="active">Active</option>

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';

View File

@@ -75,7 +75,7 @@ export default function AdminSplitsList({ splits }: AdminSplitsListProps) {
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
placeholder="Search bottles, hosts, or slugs..." placeholder="Search bottles, hosts, or slugs..."
className="w-full pl-12 pr-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-none focus:border-orange-600" className="w-full pl-12 pr-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
/> />
</div> </div>
@@ -83,7 +83,7 @@ export default function AdminSplitsList({ splits }: AdminSplitsListProps) {
<select <select
value={filterHost || ''} value={filterHost || ''}
onChange={e => setFilterHost(e.target.value || null)} onChange={e => setFilterHost(e.target.value || null)}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-none" className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden"
> >
<option value="">All Hosts</option> <option value="">All Hosts</option>
{hosts.map(([id, name]) => ( {hosts.map(([id, name]) => (
@@ -94,7 +94,7 @@ export default function AdminSplitsList({ splits }: AdminSplitsListProps) {
<select <select
value={filterStatus} value={filterStatus}
onChange={e => setFilterStatus(e.target.value as any)} onChange={e => setFilterStatus(e.target.value as any)}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-none" className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden"
> >
<option value="all">All Status</option> <option value="all">All Status</option>
<option value="active">Active</option> <option value="active">Active</option>
@@ -124,7 +124,7 @@ export default function AdminSplitsList({ splits }: AdminSplitsListProps) {
}`} }`}
> >
{/* Image */} {/* Image */}
<div className="aspect-[16/9] relative bg-zinc-800"> <div className="aspect-video relative bg-zinc-800">
{split.bottle?.image_url ? ( {split.bottle?.image_url ? (
<img <img
src={split.bottle.image_url} src={split.bottle.image_url}
@@ -147,7 +147,7 @@ export default function AdminSplitsList({ splits }: AdminSplitsListProps) {
</span> </span>
{/* Participants Badge */} {/* Participants Badge */}
<span className="absolute top-2 right-2 px-2 py-1 bg-black/60 backdrop-blur-sm text-xs font-bold text-white rounded-lg flex items-center gap-1"> <span className="absolute top-2 right-2 px-2 py-1 bg-black/60 backdrop-blur-xs text-xs font-bold text-white rounded-lg flex items-center gap-1">
<Users size={12} /> <Users size={12} />
{split.participantCount} {split.participantCount}
</span> </span>
@@ -170,7 +170,7 @@ export default function AdminSplitsList({ splits }: AdminSplitsListProps) {
</div> </div>
<div className="h-2 bg-zinc-800 rounded-full overflow-hidden"> <div className="h-2 bg-zinc-800 rounded-full overflow-hidden">
<div <div
className="h-full bg-gradient-to-r from-orange-500 to-orange-600 transition-all" className="h-full bg-linear-to-r from-orange-500 to-orange-600 transition-all"
style={{ width: `${fillPercent}%` }} style={{ width: `${fillPercent}%` }}
/> />
</div> </div>

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';

View File

@@ -88,7 +88,7 @@ export default function AdminTagsPage() {
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Tags suchen..." placeholder="Tags suchen..."
className="w-full pl-10 pr-4 py-2 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-none transition-all dark:text-zinc-200" className="w-full pl-10 pr-4 py-2 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl text-sm focus:ring-2 focus:ring-amber-500 outline-hidden transition-all dark:text-zinc-200"
/> />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -96,7 +96,7 @@ export default function AdminTagsPage() {
<select <select
value={categoryFilter} value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value as any)} onChange={(e) => setCategoryFilter(e.target.value as any)}
className="bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3 py-2 text-sm font-bold uppercase tracking-tight outline-none focus:ring-2 focus:ring-amber-500 dark:text-zinc-200" className="bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl px-3 py-2 text-sm font-bold uppercase tracking-tight outline-hidden focus:ring-2 focus:ring-amber-500 dark:text-zinc-200"
> >
<option value="all">Alle Kategorien</option> <option value="all">Alle Kategorien</option>
<option value="nose">Nose</option> <option value="nose">Nose</option>
@@ -154,7 +154,7 @@ export default function AdminTagsPage() {
key={score} key={score}
onClick={() => updatePopularity(tag.id, score)} onClick={() => updatePopularity(tag.id, score)}
className={`w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-black transition-all ${tag.popularity_score === score className={`w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-black transition-all ${tag.popularity_score === score
? 'bg-amber-600 text-white shadow-sm' ? 'bg-amber-600 text-white shadow-xs'
: 'bg-zinc-100 text-zinc-400 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700' : 'bg-zinc-100 text-zinc-400 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700'
}`} }`}
> >

View File

@@ -86,7 +86,7 @@ export default function AdminTastingsList({ tastings }: AdminTastingsListProps)
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
placeholder="Search bottles, users, or notes..." placeholder="Search bottles, users, or notes..."
className="w-full pl-12 pr-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-none focus:border-orange-600" className="w-full pl-12 pr-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600"
/> />
</div> </div>
@@ -94,7 +94,7 @@ export default function AdminTastingsList({ tastings }: AdminTastingsListProps)
<select <select
value={filterUser || ''} value={filterUser || ''}
onChange={e => setFilterUser(e.target.value || null)} onChange={e => setFilterUser(e.target.value || null)}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-none" className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden"
> >
<option value="">All Users</option> <option value="">All Users</option>
{users.map(([id, name]) => ( {users.map(([id, name]) => (
@@ -105,7 +105,7 @@ export default function AdminTastingsList({ tastings }: AdminTastingsListProps)
<select <select
value={filterRating ?? ''} value={filterRating ?? ''}
onChange={e => setFilterRating(e.target.value ? parseInt(e.target.value) : null)} onChange={e => setFilterRating(e.target.value ? parseInt(e.target.value) : null)}
className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-none" className="px-4 py-3 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-300 focus:outline-hidden"
> >
<option value="">All Ratings</option> <option value="">All Ratings</option>
<option value="5">5 Stars</option> <option value="5">5 Stars</option>
@@ -134,7 +134,7 @@ export default function AdminTastingsList({ tastings }: AdminTastingsListProps)
> >
<div className="flex gap-4"> <div className="flex gap-4">
{/* Bottle Image */} {/* Bottle Image */}
<div className="w-16 h-16 rounded-xl overflow-hidden bg-zinc-800 flex-shrink-0"> <div className="w-16 h-16 rounded-xl overflow-hidden bg-zinc-800 shrink-0">
{tasting.bottle?.image_url ? ( {tasting.bottle?.image_url ? (
<img <img
src={tasting.bottle.image_url} src={tasting.bottle.image_url}
@@ -159,7 +159,7 @@ export default function AdminTastingsList({ tastings }: AdminTastingsListProps)
{tasting.bottle?.distillery || 'Unknown Distillery'} {tasting.bottle?.distillery || 'Unknown Distillery'}
</p> </p>
</div> </div>
<div className="flex-shrink-0"> <div className="shrink-0">
{tasting.rating > 0 ? renderStars(tasting.rating) : ( {tasting.rating > 0 ? renderStars(tasting.rating) : (
<span className="text-xs text-zinc-600">No rating</span> <span className="text-xs text-zinc-600">No rating</span>
)} )}

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { checkIsAdmin } from '@/services/track-api-usage'; import { checkIsAdmin } from '@/services/track-api-usage';
@@ -53,7 +52,7 @@ export default async function AdminUsersPage() {
{/* Statistics Cards */} {/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg"> <div className="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<Users size={20} className="text-blue-600 dark:text-blue-400" /> <Users size={20} className="text-blue-600 dark:text-blue-400" />
@@ -63,7 +62,7 @@ export default async function AdminUsersPage() {
<div className="text-3xl font-black text-zinc-900 dark:text-white">{totalUsers}</div> <div className="text-3xl font-black text-zinc-900 dark:text-white">{totalUsers}</div>
</div> </div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg"> <div className="p-2 bg-green-100 dark:bg-green-900/30 rounded-lg">
<Coins size={20} className="text-green-600 dark:text-green-400" /> <Coins size={20} className="text-green-600 dark:text-green-400" />
@@ -73,7 +72,7 @@ export default async function AdminUsersPage() {
<div className="text-3xl font-black text-zinc-900 dark:text-white">{totalCreditsInCirculation.toLocaleString()}</div> <div className="text-3xl font-black text-zinc-900 dark:text-white">{totalCreditsInCirculation.toLocaleString()}</div>
</div> </div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg"> <div className="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<TrendingUp size={20} className="text-amber-600 dark:text-amber-400" /> <TrendingUp size={20} className="text-amber-600 dark:text-amber-400" />
@@ -83,7 +82,7 @@ export default async function AdminUsersPage() {
<div className="text-3xl font-black text-zinc-900 dark:text-white">{totalCreditsPurchased.toLocaleString()}</div> <div className="text-3xl font-black text-zinc-900 dark:text-white">{totalCreditsPurchased.toLocaleString()}</div>
</div> </div>
<div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-sm"> <div className="bg-white dark:bg-zinc-900 rounded-2xl p-6 border border-zinc-200 dark:border-zinc-800 shadow-xs">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg"> <div className="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
<TrendingDown size={20} className="text-purple-600 dark:text-purple-400" /> <TrendingDown size={20} className="text-purple-600 dark:text-purple-400" />

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import sharp from 'sharp'; import sharp from 'sharp';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';

View File

@@ -1,4 +1,3 @@
export const dynamic = 'force-dynamic';
import { createClient } from '@/lib/supabase/server'; import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';

View File

@@ -111,7 +111,7 @@ export default function BuddiesPage() {
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
placeholder={t('buddy.placeholder')} placeholder={t('buddy.placeholder')}
className="flex-1 bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-3 text-white placeholder-zinc-600 focus:outline-none focus:border-orange-600 transition-colors" className="flex-1 bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-3 text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
<button <button
type="submit" type="submit"
@@ -138,7 +138,7 @@ export default function BuddiesPage() {
placeholder="Search buddies..." placeholder="Search buddies..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 bg-zinc-900 border border-zinc-800 rounded-xl text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-orange-600/50" className="w-full pl-9 pr-4 py-2.5 bg-zinc-900 border border-zinc-800 rounded-xl text-sm text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600/50"
/> />
</div> </div>
)} )}

View File

@@ -1,6 +1,58 @@
@tailwind base; @import 'tailwindcss';
@tailwind components;
@tailwind utilities; @theme {
--color-zinc-50: #fafafa;
--color-zinc-100: #f4f4f5;
--color-zinc-200: #e4e4e7;
--color-zinc-300: #d4d4d8;
--color-zinc-400: #a8a8b3;
--color-zinc-500: #8a8a95;
--color-zinc-600: #6b6b75;
--color-zinc-700: #4a4a52;
--color-zinc-800: #2a2a2e;
--color-zinc-900: #1a1a1e;
--color-zinc-950: #0d0d0f;
--background-image-gradient-radial: radial-gradient(var(--tw-gradient-stops));
--background-image-gradient-conic: conic-gradient(
from 180deg at 50% 50%,
var(--tw-gradient-stops)
);
}
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still
looks the same as it did with Tailwind CSS v3.
If we ever want to remove these styles, we need to add an explicit border
color utility to any element that depends on these defaults.
*/
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@utility glass {
@apply backdrop-blur-md bg-zinc-900/50 border border-zinc-800/50;
}
@utility glass-dark {
@apply backdrop-blur-md bg-zinc-950/80 border border-zinc-900/50;
}
@utility scrollbar-hide {
&::-webkit-scrollbar {
display: none;
}
-ms-overflow-style: none;
scrollbar-width: none;
}
@layer base { @layer base {
:root { :root {
@@ -20,16 +72,17 @@
} }
} }
@layer utilities {
body { body {
@apply bg-[#1c1c1e] text-[#fafafa] antialiased selection:bg-orange-500/30; @apply bg-[#1c1c1e] text-[#fafafa] antialiased selection:bg-orange-500/30;
font-feature-settings: "cv02", "cv03", "cv04", "cv11"; font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
} }
/* Global Input Text Fix */ /* Global Input Text Fix */
input, input,
textarea, textarea,
select { select {
@apply bg-zinc-950 text-white border-zinc-800 focus:ring-1 focus:ring-orange-600 outline-none transition-all; @apply bg-zinc-950 text-white border-zinc-800 focus:ring-1 focus:ring-orange-600 outline-hidden transition-all;
} }
input::placeholder, input::placeholder,
@@ -45,22 +98,4 @@ h4,
font-family: var(--font-inter), system-ui, sans-serif; font-family: var(--font-inter), system-ui, sans-serif;
letter-spacing: -0.02em; letter-spacing: -0.02em;
} }
@layer utilities {
.glass {
@apply backdrop-blur-md bg-zinc-900/50 border border-zinc-800/50;
}
.glass-dark {
@apply backdrop-blur-md bg-zinc-950/80 border border-zinc-900/50;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
} }

View File

@@ -2,8 +2,8 @@ import { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest { export default function manifest(): MetadataRoute.Manifest {
return { return {
name: 'Whisky Vault', name: 'Dramlog.eu',
short_name: 'WhiskyVault', short_name: 'Dramlog',
description: 'Dein persönlicher Whisky-Begleiter zum Scannen und Verkosten.', description: 'Dein persönlicher Whisky-Begleiter zum Scannen und Verkosten.',
start_url: '/', start_url: '/',
display: 'standalone', display: 'standalone',

View File

@@ -5,13 +5,12 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import BottleGrid from "@/components/BottleGrid"; import BottleGrid from "@/components/BottleGrid";
import AuthForm from "@/components/AuthForm"; import AuthForm from "@/components/AuthForm";
import LanguageSwitcher from "@/components/LanguageSwitcher";
import OfflineIndicator from "@/components/OfflineIndicator"; import OfflineIndicator from "@/components/OfflineIndicator";
import { useI18n } from "@/i18n/I18nContext"; import { useI18n } from "@/i18n/I18nContext";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { useSession } from "@/context/SessionContext"; import { useSession } from "@/context/SessionContext";
import TastingHub from "@/components/TastingHub"; import TastingHub from "@/components/TastingHub";
import { Sparkles, Loader2, Search, SlidersHorizontal, Settings, CircleUser } from "lucide-react"; import { Sparkles, Loader2, Search, SlidersHorizontal } from "lucide-react";
import { BottomNavigation } from '@/components/BottomNavigation'; import { BottomNavigation } from '@/components/BottomNavigation';
import ScanAndTasteFlow from '@/components/ScanAndTasteFlow'; import ScanAndTasteFlow from '@/components/ScanAndTasteFlow';
import UserStatusBadge from '@/components/UserStatusBadge'; import UserStatusBadge from '@/components/UserStatusBadge';
@@ -19,7 +18,6 @@ import { getActiveSplits } from '@/services/split-actions';
import SplitCard from '@/components/SplitCard'; import SplitCard from '@/components/SplitCard';
import HeroBanner from '@/components/HeroBanner'; import HeroBanner from '@/components/HeroBanner';
import QuickActionsGrid from '@/components/QuickActionsGrid'; import QuickActionsGrid from '@/components/QuickActionsGrid';
import DramOfTheDay from '@/components/DramOfTheDay';
import { checkIsAdmin } from '@/services/track-api-usage'; import { checkIsAdmin } from '@/services/track-api-usage';
export default function Home() { export default function Home() {
@@ -152,9 +150,6 @@ export default function Home() {
<p className="text-zinc-500 max-w-sm mx-auto font-bold tracking-wide"> <p className="text-zinc-500 max-w-sm mx-auto font-bold tracking-wide">
{t('home.tagline')} {t('home.tagline')}
</p> </p>
<div className="mt-8">
<LanguageSwitcher />
</div>
</div> </div>
<AuthForm /> <AuthForm />
@@ -185,18 +180,28 @@ export default function Home() {
// Authenticated Home View - New Layout // Authenticated Home View - New Layout
return ( return (
<div className="flex flex-col min-h-screen bg-[var(--background)] relative"> <div className="flex flex-col min-h-screen bg-(--background) relative">
{/* Scrollable Content Area */} {/* Scrollable Content Area */}
<div className="flex-1 overflow-y-auto pb-24"> <div className="flex-1 overflow-y-auto pb-24">
{/* 1. Header */} {/* 1. Header */}
<header className="px-4 pt-4 pb-2"> <header className="px-4 pt-4 pb-2 space-y-2">
{/* Row 1: Logo + Logout */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex flex-col">
<h1 className="text-2xl font-bold text-zinc-50 tracking-tighter"> <h1 className="text-2xl font-bold text-zinc-50 tracking-tighter">
DRAM<span className="text-orange-600">LOG</span> DRAM<span className="text-orange-600">LOG</span>
</h1> </h1>
{activeSession && ( <button
<div className="flex items-center gap-2 mt-0.5 animate-in fade-in slide-in-from-left-2 duration-700"> onClick={handleLogout}
className="text-[10px] font-bold uppercase tracking-widest text-zinc-500 hover:text-white transition-colors"
>
{t('home.logout')}
</button>
</div>
{/* Row 2: Session info + Status */}
<div className="flex items-center justify-between">
{activeSession ? (
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-2 duration-700">
<div className="relative flex h-2 w-2"> <div className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-600 opacity-75"></span> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-orange-600 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-orange-600"></span> <span className="relative inline-flex rounded-full h-2 w-2 bg-orange-600"></span>
@@ -206,19 +211,12 @@ export default function Home() {
Live: {activeSession.name} Live: {activeSession.name}
</span> </span>
</div> </div>
) : (
<div />
)} )}
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<UserStatusBadge /> <UserStatusBadge />
<OfflineIndicator /> <OfflineIndicator />
<LanguageSwitcher />
<DramOfTheDay bottles={bottles} />
<button
onClick={handleLogout}
className="text-[9px] font-bold uppercase tracking-widest text-zinc-600 hover:text-white transition-colors"
>
{t('home.logout')}
</button>
</div> </div>
</div> </div>
</header> </header>
@@ -244,7 +242,7 @@ export default function Home() {
placeholder={t('home.searchPlaceholder') || 'Search collection...'} placeholder={t('home.searchPlaceholder') || 'Search collection...'}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 bg-zinc-900 border border-zinc-800 rounded-xl text-sm text-white placeholder-zinc-600 focus:outline-none focus:border-orange-600/50 focus:ring-1 focus:ring-orange-600/20" className="w-full pl-9 pr-4 py-2.5 bg-zinc-900 border border-zinc-800 rounded-xl text-sm text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600/50 focus:ring-1 focus:ring-orange-600/20"
/> />
</div> </div>
<button className="p-2.5 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-500 hover:text-white hover:border-zinc-700 transition-colors"> <button className="p-2.5 bg-zinc-900 border border-zinc-800 rounded-xl text-zinc-500 hover:text-white hover:border-zinc-700 transition-colors">

View File

@@ -308,7 +308,7 @@ export default function SessionDetailPage() {
} }
return ( return (
<main className="min-h-screen bg-[var(--background)] p-4 md:p-12 lg:p-24 pb-32"> <main className="min-h-screen bg-(--background) p-4 md:p-12 lg:p-24 pb-32">
<div className="max-w-6xl mx-auto space-y-12"> <div className="max-w-6xl mx-auto space-y-12">
{/* Back Link & Info */} {/* Back Link & Info */}
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
@@ -337,7 +337,7 @@ export default function SessionDetailPage() {
{/* Immersive Header */} {/* Immersive Header */}
<header className="relative bg-zinc-900 border border-white/5 rounded-[48px] p-8 md:p-12 shadow-[0_20px_80px_rgba(0,0,0,0.5)] overflow-hidden group"> <header className="relative bg-zinc-900 border border-white/5 rounded-[48px] p-8 md:p-12 shadow-[0_20px_80px_rgba(0,0,0,0.5)] overflow-hidden group">
{/* Background Visuals */} {/* Background Visuals */}
<div className="absolute inset-0 bg-gradient-to-br from-zinc-900 via-zinc-900 to-black z-0" /> <div className="absolute inset-0 bg-linear-to-br from-zinc-900 via-zinc-900 to-black z-0" />
{tastings.length > 0 && tastings[0].bottles.image_url && ( {tastings.length > 0 && tastings[0].bottles.image_url && (
<div className="absolute top-0 right-0 w-2/3 h-full opacity-30 z-0"> <div className="absolute top-0 right-0 w-2/3 h-full opacity-30 z-0">
<div <div
@@ -570,7 +570,7 @@ export default function SessionDetailPage() {
if (e.target.value) handleAddParticipant(e.target.value); if (e.target.value) handleAddParticipant(e.target.value);
e.target.value = ""; e.target.value = "";
}} }}
className="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-4 py-3 text-[10px] font-black uppercase tracking-wider text-zinc-400 outline-none focus:border-orange-500/50 transition-colors appearance-none" className="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-4 py-3 text-[10px] font-black uppercase tracking-wider text-zinc-400 outline-hidden focus:border-orange-500/50 transition-colors appearance-none"
style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'%23a1a1aa\'%3E%3Cpath stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M19 9l-7 7-7-7\'/%3E%3C/svg%3E")', backgroundRepeat: 'no-repeat', backgroundPosition: 'right 1rem center', backgroundSize: '1rem' }} style={{ backgroundImage: 'url("data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' fill=\'none\' viewBox=\'0 0 24 24\' stroke=\'%23a1a1aa\'%3E%3Cpath stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M19 9l-7 7-7-7\'/%3E%3C/svg%3E")', backgroundRepeat: 'no-repeat', backgroundPosition: 'right 1rem center', backgroundSize: '1rem' }}
> >
<option value="">Auswahl...</option> <option value="">Auswahl...</option>

View File

@@ -160,7 +160,7 @@ export default function SessionsPage() {
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
placeholder={t('session.sessionName')} placeholder={t('session.sessionName')}
className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-white placeholder-zinc-600 focus:outline-none focus:border-orange-600 mb-3" className="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-white placeholder-zinc-600 focus:outline-hidden focus:border-orange-600 mb-3"
autoFocus autoFocus
/> />
<div className="flex gap-2"> <div className="flex gap-2">
@@ -186,7 +186,7 @@ export default function SessionsPage() {
{/* Active Session Banner */} {/* Active Session Banner */}
{activeSession && ( {activeSession && (
<Link href={`/sessions/${activeSession.id}`}> <Link href={`/sessions/${activeSession.id}`}>
<div className="mb-6 p-4 bg-gradient-to-r from-orange-600/20 to-orange-500/10 rounded-2xl border border-orange-600/30 flex items-center gap-4 group hover:border-orange-500/50 transition-colors"> <div className="mb-6 p-4 bg-linear-to-r from-orange-600/20 to-orange-500/10 rounded-2xl border border-orange-600/30 flex items-center gap-4 group hover:border-orange-500/50 transition-colors">
<div className="relative"> <div className="relative">
<div className="w-12 h-12 rounded-xl bg-orange-600/20 flex items-center justify-center"> <div className="w-12 h-12 rounded-xl bg-orange-600/20 flex items-center justify-center">
<Sparkles size={24} className="text-orange-500" /> <Sparkles size={24} className="text-orange-500" />

View File

@@ -135,7 +135,7 @@ export default function SplitPublicPage() {
alt={split.bottle.name} alt={split.bottle.name}
className="w-full h-full object-cover opacity-80" className="w-full h-full object-cover opacity-80"
/> />
<div className="absolute inset-0 bg-gradient-to-t from-zinc-900 via-transparent to-transparent" /> <div className="absolute inset-0 bg-linear-to-t from-zinc-900 via-transparent to-transparent" />
</div> </div>
)} )}

View File

@@ -158,7 +158,7 @@ export default function SplitManagePage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<p className="font-bold text-white truncate">{split.bottleName}</p> <p className="font-bold text-white truncate">{split.bottleName}</p>
{!split.isActive && ( {!split.isActive && (
<span className="px-2 py-0.5 bg-zinc-800 rounded text-[10px] font-bold text-zinc-500"> <span className="px-2 py-0.5 bg-zinc-800 rounded-sm text-[10px] font-bold text-zinc-500">
Geschlossen Geschlossen
</span> </span>
)} )}

View File

@@ -6,6 +6,7 @@ import AnalyticsDashboard from '@/components/AnalyticsDashboard';
import { useI18n } from '@/i18n/I18nContext'; import { useI18n } from '@/i18n/I18nContext';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { ChartSkeleton, StatsCardSkeleton } from '@/components/Skeletons';
export default function StatsPage() { export default function StatsPage() {
const router = useRouter(); const router = useRouter();
@@ -59,8 +60,20 @@ export default function StatsPage() {
{/* Dashboard */} {/* Dashboard */}
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-20"> <div className="space-y-6">
<div className="w-8 h-8 border-2 border-orange-500 border-t-transparent rounded-full animate-spin"></div> {/* KPI Cards Skeleton */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatsCardSkeleton />
<StatsCardSkeleton />
<StatsCardSkeleton />
<StatsCardSkeleton />
</div>
{/* Charts Skeleton */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<ChartSkeleton height={300} />
<ChartSkeleton height={300} />
</div>
<ChartSkeleton height={400} />
</div> </div>
) : ( ) : (
<AnalyticsDashboard bottles={bottles} /> <AnalyticsDashboard bottles={bottles} />

View File

@@ -19,7 +19,7 @@ export default function ActiveSessionBanner() {
initial={{ y: 50, opacity: 0, x: '-50%' }} initial={{ y: 50, opacity: 0, x: '-50%' }}
animate={{ y: 0, opacity: 1, x: '-50%' }} animate={{ y: 0, opacity: 1, x: '-50%' }}
exit={{ y: 50, opacity: 0, x: '-50%' }} exit={{ y: 50, opacity: 0, x: '-50%' }}
className="fixed bottom-32 left-1/2 z-[50] w-[calc(100%-2rem)] max-w-sm" className="fixed bottom-32 left-1/2 z-50 w-[calc(100%-2rem)] max-w-sm"
> >
<div className="bg-zinc-900/90 backdrop-blur-2xl border border-orange-500/20 rounded-[32px] p-2 flex items-center justify-between shadow-2xl ring-1 ring-white/5 overflow-hidden"> <div className="bg-zinc-900/90 backdrop-blur-2xl border border-orange-500/20 rounded-[32px] p-2 flex items-center justify-between shadow-2xl ring-1 ring-white/5 overflow-hidden">
{/* Session Info Link */} {/* Session Info Link */}

View File

@@ -133,7 +133,7 @@ export default function AuthForm() {
placeholder="dein_username" placeholder="dein_username"
required required
maxLength={20} maxLength={20}
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-none transition-all text-white placeholder:text-zinc-600" className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-hidden transition-all text-white placeholder:text-zinc-600"
/> />
</div> </div>
<p className="text-[10px] text-zinc-600 ml-1">Nur Kleinbuchstaben, Zahlen und _</p> <p className="text-[10px] text-zinc-600 ml-1">Nur Kleinbuchstaben, Zahlen und _</p>
@@ -149,7 +149,7 @@ export default function AuthForm() {
onChange={(e) => setFullName(e.target.value)} onChange={(e) => setFullName(e.target.value)}
placeholder="Max Mustermann" placeholder="Max Mustermann"
maxLength={50} maxLength={50}
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-none transition-all text-white placeholder:text-zinc-600" className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-hidden transition-all text-white placeholder:text-zinc-600"
/> />
</div> </div>
</div> </div>
@@ -166,7 +166,7 @@ export default function AuthForm() {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="name@beispiel.de" placeholder="name@beispiel.de"
required required
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-none transition-all text-white placeholder:text-zinc-600" className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-hidden transition-all text-white placeholder:text-zinc-600"
/> />
</div> </div>
</div> </div>
@@ -181,7 +181,7 @@ export default function AuthForm() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
required required
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-none transition-all text-white placeholder:text-zinc-600" className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl focus:ring-1 focus:ring-orange-600 focus:border-transparent outline-hidden transition-all text-white placeholder:text-zinc-600"
/> />
</div> </div>
</div> </div>

View File

@@ -30,7 +30,7 @@ export default function AvatarStack({ names, limit = 3, size = 'sm' }: AvatarSta
{visibleNames.map((name, i) => ( {visibleNames.map((name, i) => (
<div <div
key={`${name}-${i}`} key={`${name}-${i}`}
className={`${sizeClasses} rounded-full bg-orange-900/30 border-2 border-zinc-950 flex items-center justify-center text-orange-400 font-bold ring-1 ring-orange-500/10 shadow-sm relative group`} className={`${sizeClasses} rounded-full bg-orange-900/30 border-2 border-zinc-950 flex items-center justify-center text-orange-400 font-bold ring-1 ring-orange-500/10 shadow-xs relative group`}
title={name} title={name}
> >
{getInitials(name)} {getInitials(name)}
@@ -42,7 +42,7 @@ export default function AvatarStack({ names, limit = 3, size = 'sm' }: AvatarSta
))} ))}
{extraCount > 0 && ( {extraCount > 0 && (
<div <div
className={`${sizeClasses} rounded-full bg-zinc-100 dark:bg-zinc-800 border-2 border-white dark:border-zinc-900 flex items-center justify-center text-zinc-500 dark:text-zinc-400 font-black ring-1 ring-zinc-500/10 shadow-sm relative group`} className={`${sizeClasses} rounded-full bg-zinc-100 dark:bg-zinc-800 border-2 border-white dark:border-zinc-900 flex items-center justify-center text-zinc-500 dark:text-zinc-400 font-black ring-1 ring-zinc-500/10 shadow-xs relative group`}
title={`${extraCount} weitere Personen`} title={`${extraCount} weitere Personen`}
> >
+{extraCount} +{extraCount}

View File

@@ -98,7 +98,7 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
return ( return (
<div className="max-w-4xl mx-auto pb-24"> <div className="max-w-4xl mx-auto pb-24">
{/* Header / Hero Section */} {/* Header / Hero Section */}
<div className="relative w-full overflow-hidden bg-[var(--surface)] shadow-2xl"> <div className="relative w-full overflow-hidden bg-(--surface) shadow-2xl">
{/* Back Button Overlay */} {/* Back Button Overlay */}
<div className="absolute top-6 left-6 z-20"> <div className="absolute top-6 left-6 z-20">
<Link <Link
@@ -110,9 +110,9 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
</div> </div>
{/* Hero Image - Slightly More Compact Aspect for better title flow */} {/* Hero Image - Slightly More Compact Aspect for better title flow */}
<div className="relative aspect-[4/3] md:aspect-[16/8] w-full flex items-center justify-center p-6 md:p-10 overflow-hidden"> <div className="relative aspect-4/3 md:aspect-16/8 w-full flex items-center justify-center p-6 md:p-10 overflow-hidden">
{/* Background Glow */} {/* Background Glow */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-orange-600/10 via-transparent to-transparent opacity-30" /> <div className="absolute inset-0 bg-[radial-gradient(circle_at_center,var(--tw-gradient-stops))] from-orange-600/10 via-transparent to-transparent opacity-30" />
<img <img
src={getStorageUrl(bottle.image_url)} src={getStorageUrl(bottle.image_url)}
alt={bottle.name} alt={bottle.name}
@@ -121,7 +121,7 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
</div> </div>
{/* Info Overlay - Mobile Gradient */} {/* Info Overlay - Mobile Gradient */}
<div className="absolute inset-x-0 bottom-0 h-48 bg-gradient-to-t from-[var(--background)] to-transparent pointer-events-none" /> <div className="absolute inset-x-0 bottom-0 h-48 bg-linear-to-t from-(--background) to-transparent pointer-events-none" />
</div> </div>
{/* Content Container */} {/* Content Container */}
@@ -134,7 +134,7 @@ export default function BottleDetails({ bottleId, sessionId, userId }: BottleDet
<p className="text-[9px] font-black uppercase tracking-widest text-orange-500">Offline Mode</p> <p className="text-[9px] font-black uppercase tracking-widest text-orange-500">Offline Mode</p>
</div> </div>
)} )}
<h2 className="text-xs md:text-sm font-black text-orange-500 uppercase tracking-[0.25em] drop-shadow-sm"> <h2 className="text-xs md:text-sm font-black text-orange-500 uppercase tracking-[0.25em] drop-shadow-xs">
{bottle.distillery || 'Unknown Distillery'} {bottle.distillery || 'Unknown Distillery'}
</h2> </h2>
<h1 className="text-4xl md:text-6xl font-extrabold text-white tracking-tight leading-[1.05] drop-shadow-md"> <h1 className="text-4xl md:text-6xl font-extrabold text-white tracking-tight leading-[1.05] drop-shadow-md">

View File

@@ -37,10 +37,10 @@ function BottleCard({ bottle, sessionId }: BottleCardProps) {
return ( return (
<Link <Link
href={`/bottles/${bottle.id}${sessionId ? `?session_id=${sessionId}` : ''}`} href={`/bottles/${bottle.id}${sessionId ? `?session_id=${sessionId}` : ''}`}
className="block h-full group relative overflow-hidden rounded-2xl bg-zinc-900 border border-white/[0.05] transition-all duration-500 hover:border-orange-500/30 hover:shadow-2xl hover:shadow-orange-950/20 active:scale-[0.98]" className="block h-full group relative overflow-hidden rounded-2xl bg-zinc-900 border border-white/5 transition-all duration-500 hover:border-orange-500/30 hover:shadow-2xl hover:shadow-orange-950/20 active:scale-[0.98]"
> >
{/* === SPOTIFY-STYLE IMAGE SECTION === */} {/* === SPOTIFY-STYLE IMAGE SECTION === */}
<div className="relative aspect-[3/4] overflow-hidden"> <div className="relative aspect-3/4 overflow-hidden">
{/* Layer 1: Blurred Backdrop */} {/* Layer 1: Blurred Backdrop */}
<div className="absolute inset-0 z-0"> <div className="absolute inset-0 z-0">
@@ -103,10 +103,10 @@ function BottleCard({ bottle, sessionId }: BottleCardProps) {
</h3> </h3>
<div className="flex flex-wrap gap-2 mt-3"> <div className="flex flex-wrap gap-2 mt-3">
<span className="px-2 py-1 bg-white/10 backdrop-blur-sm text-zinc-300 text-[9px] font-bold uppercase tracking-widest rounded-md"> <span className="px-2 py-1 bg-white/10 backdrop-blur-xs text-zinc-300 text-[9px] font-bold uppercase tracking-widest rounded-md">
{shortenCategory(bottle.category)} {shortenCategory(bottle.category)}
</span> </span>
<span className="px-2 py-1 bg-white/10 backdrop-blur-sm text-zinc-300 text-[9px] font-bold uppercase tracking-widest rounded-md"> <span className="px-2 py-1 bg-white/10 backdrop-blur-xs text-zinc-300 text-[9px] font-bold uppercase tracking-widest rounded-md">
{bottle.abv}% VOL {bottle.abv}% VOL
</span> </span>
</div> </div>
@@ -216,7 +216,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
placeholder={t('grid.searchPlaceholder')} placeholder={t('grid.searchPlaceholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-8 pr-8 py-4 bg-transparent border-b border-zinc-800 focus:border-orange-500 outline-none transition-all text-zinc-50 placeholder:text-zinc-500" className="w-full pl-8 pr-8 py-4 bg-transparent border-b border-zinc-800 focus:border-orange-500 outline-hidden transition-all text-zinc-50 placeholder:text-zinc-500"
/> />
{searchQuery && ( {searchQuery && (
<button <button
@@ -232,7 +232,7 @@ export default function BottleGrid({ bottles }: BottleGridProps) {
<select <select
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value as any)} onChange={(e) => setSortBy(e.target.value as any)}
className="bg-transparent border-none text-zinc-500 text-xs font-bold uppercase tracking-widest outline-none cursor-pointer hover:text-white transition-colors appearance-none" className="bg-transparent border-none text-zinc-500 text-xs font-bold uppercase tracking-widest outline-hidden cursor-pointer hover:text-white transition-colors appearance-none"
> >
<option value="created_at" className="bg-zinc-950">{t('grid.sortBy.createdAt')}</option> <option value="created_at" className="bg-zinc-950">{t('grid.sortBy.createdAt')}</option>
<option value="last_tasted" className="bg-zinc-950">{t('grid.sortBy.lastTasted')}</option> <option value="last_tasted" className="bg-zinc-950">{t('grid.sortBy.lastTasted')}</option>

View File

@@ -32,7 +32,7 @@ export default function BottleSkeletonCard({
}`} }`}
> >
{/* Image */} {/* Image */}
<div className="aspect-[3/4] bg-zinc-950 relative overflow-hidden"> <div className="aspect-3/4 bg-zinc-950 relative overflow-hidden">
{imageUrl ? ( {imageUrl ? (
<img <img
src={imageUrl} src={imageUrl}
@@ -81,12 +81,12 @@ export default function BottleSkeletonCard({
{/* Info */} {/* Info */}
<div className="p-3"> <div className="p-3">
{/* Skeleton Name */} {/* Skeleton Name */}
<div className="h-4 bg-zinc-800 rounded animate-pulse mb-2 w-3/4" /> <div className="h-4 bg-zinc-800 rounded-sm animate-pulse mb-2 w-3/4" />
{/* Skeleton Details */} {/* Skeleton Details */}
<div className="flex gap-2"> <div className="flex gap-2">
<div className="h-3 bg-zinc-800/50 rounded animate-pulse w-12" /> <div className="h-3 bg-zinc-800/50 rounded-sm animate-pulse w-12" />
<div className="h-3 bg-zinc-800/50 rounded animate-pulse w-8" /> <div className="h-3 bg-zinc-800/50 rounded-sm animate-pulse w-8" />
</div> </div>
</div> </div>
</motion.div> </motion.div>

View File

@@ -106,7 +106,7 @@ export default function BuddyHandshake({ isOpen, onClose, onSuccess }: BuddyHand
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" className="fixed inset-0 bg-black/80 backdrop-blur-xs z-50 flex items-center justify-center p-4"
onClick={onClose} onClick={onClose}
> >
<motion.div <motion.div
@@ -240,7 +240,7 @@ export default function BuddyHandshake({ isOpen, onClose, onSuccess }: BuddyHand
} }
}} }}
placeholder="XXXXXX" placeholder="XXXXXX"
className="w-full text-center text-3xl font-black tracking-[0.4em] bg-zinc-950 border-2 border-zinc-800 rounded-2xl py-4 text-white placeholder:text-zinc-700 focus:outline-none focus:border-orange-500 transition-colors font-mono" className="w-full text-center text-3xl font-black tracking-[0.4em] bg-zinc-950 border-2 border-zinc-800 rounded-2xl py-4 text-white placeholder:text-zinc-700 focus:outline-hidden focus:border-orange-500 transition-colors font-mono"
maxLength={6} maxLength={6}
autoFocus autoFocus
/> />

View File

@@ -112,7 +112,7 @@ export default function BuddyList() {
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
placeholder={t('buddy.placeholder')} placeholder={t('buddy.placeholder')}
className="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:outline-none focus:border-orange-600 transition-colors" className="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
<button <button
type="submit" type="submit"
@@ -183,12 +183,12 @@ export default function BuddyList() {
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-top-1"> <div className="flex items-center gap-2 animate-in fade-in slide-in-from-top-1">
<div className="flex -space-x-1.5 overflow-hidden"> <div className="flex -space-x-1.5 overflow-hidden">
{buddies.slice(0, 5).map((b, i) => ( {buddies.slice(0, 5).map((b, i) => (
<div key={b.id} className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[10px] font-bold text-orange-600 shadow-sm"> <div key={b.id} className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[10px] font-bold text-orange-600 shadow-xs">
{b.name[0].toUpperCase()} {b.name[0].toUpperCase()}
</div> </div>
))} ))}
{buddies.length > 5 && ( {buddies.length > 5 && (
<div className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[8px] font-bold text-zinc-500 shadow-sm"> <div className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[8px] font-bold text-zinc-500 shadow-xs">
+{buddies.length - 5} +{buddies.length - 5}
</div> </div>
)} )}

View File

@@ -122,7 +122,7 @@ export default function BulkScanSheet({
className="fixed inset-0 bg-black z-50 flex flex-col" className="fixed inset-0 bg-black z-50 flex flex-col"
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 bg-zinc-900/80 backdrop-blur-sm border-b border-zinc-800"> <div className="flex items-center justify-between p-4 bg-zinc-900/80 backdrop-blur-xs border-b border-zinc-800">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-orange-600/20 flex items-center justify-center"> <div className="w-10 h-10 rounded-xl bg-orange-600/20 flex items-center justify-center">
<Zap size={20} className="text-orange-500" /> <Zap size={20} className="text-orange-500" />
@@ -240,7 +240,7 @@ export default function BulkScanSheet({
<X size={12} className="text-white" /> <X size={12} className="text-white" />
</button> </button>
)} )}
<span className="absolute bottom-0.5 left-0.5 text-[9px] font-bold text-white bg-black/60 px-1 rounded"> <span className="absolute bottom-0.5 left-0.5 text-[9px] font-bold text-white bg-black/60 px-1 rounded-sm">
#{i + 1} #{i + 1}
</span> </span>
</motion.div> </motion.div>

View File

@@ -473,7 +473,7 @@ export default function CameraCapture({ onImageCaptured, onAnalysisComplete, onS
)} )}
{isQueued && ( {isQueued && (
<div className="flex flex-col gap-3 p-5 bg-gradient-to-br from-purple-500/10 to-amber-500/10 dark:from-purple-900/20 dark:to-amber-900/20 border border-purple-500/20 rounded-3xl w-full animate-in zoom-in-95 duration-500"> <div className="flex flex-col gap-3 p-5 bg-linear-to-br from-purple-500/10 to-amber-500/10 dark:from-purple-900/20 dark:to-amber-900/20 border border-purple-500/20 rounded-3xl w-full animate-in zoom-in-95 duration-500">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-purple-500 flex items-center justify-center text-white shadow-lg shadow-purple-500/30"><Sparkles size={20} /></div> <div className="w-10 h-10 rounded-2xl bg-purple-500 flex items-center justify-center text-white shadow-lg shadow-purple-500/30"><Sparkles size={20} /></div>
<div className="flex flex-col"> <div className="flex flex-col">

View File

@@ -24,7 +24,7 @@ export default function CookieBanner() {
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }} exit={{ y: 100, opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }} transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed bottom-0 left-0 right-0 z-[100] p-4 md:p-6" className="fixed bottom-0 left-0 right-0 z-100 p-4 md:p-6"
> >
<div className="max-w-4xl mx-auto bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl overflow-hidden"> <div className="max-w-4xl mx-auto bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl overflow-hidden">
{/* Header */} {/* Header */}

View File

@@ -55,7 +55,7 @@ export default function DramOfTheDay({ bottles }: DramOfTheDayProps) {
</button> </button>
{suggestion && ( {suggestion && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-6 bg-zinc-950/80 backdrop-blur-sm animate-in fade-in duration-300"> <div className="fixed inset-0 z-100 flex items-center justify-center p-6 bg-zinc-950/80 backdrop-blur-xs animate-in fade-in duration-300">
<div className="bg-zinc-900 w-full max-w-sm rounded-[40px] p-8 shadow-2xl border border-orange-500/20 relative animate-in zoom-in-95 duration-300"> <div className="bg-zinc-900 w-full max-w-sm rounded-[40px] p-8 shadow-2xl border border-orange-500/20 relative animate-in zoom-in-95 duration-300">
<button <button
onClick={() => setSuggestion(null)} onClick={() => setSuggestion(null)}

View File

@@ -115,7 +115,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
type="text" type="text"
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
/> />
</div> </div>
@@ -125,7 +125,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
type="text" type="text"
value={formData.distillery} value={formData.distillery}
onChange={(e) => setFormData({ ...formData, distillery: e.target.value })} onChange={(e) => setFormData({ ...formData, distillery: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
/> />
</div> </div>
@@ -136,7 +136,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
type="text" type="text"
value={formData.category} value={formData.category}
onChange={(e) => setFormData({ ...formData, category: e.target.value })} onChange={(e) => setFormData({ ...formData, category: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
/> />
</div> </div>
@@ -149,7 +149,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
inputMode="decimal" inputMode="decimal"
value={formData.abv} value={formData.abv}
onChange={(e) => setFormData({ ...formData, abv: e.target.value })} onChange={(e) => setFormData({ ...formData, abv: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-orange-500 text-sm font-bold transition-all" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-orange-500 text-sm font-bold transition-all"
placeholder="e.g. 46.3" placeholder="e.g. 46.3"
/> />
</div> </div>
@@ -160,7 +160,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
inputMode="numeric" inputMode="numeric"
value={formData.age} value={formData.age}
onChange={(e) => setFormData({ ...formData, age: e.target.value })} onChange={(e) => setFormData({ ...formData, age: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all"
placeholder="e.g. 12" placeholder="e.g. 12"
/> />
</div> </div>
@@ -176,7 +176,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
placeholder="YYYY" placeholder="YYYY"
value={formData.distilled_at} value={formData.distilled_at}
onChange={(e) => setFormData({ ...formData, distilled_at: e.target.value })} onChange={(e) => setFormData({ ...formData, distilled_at: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -187,7 +187,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
placeholder="YYYY" placeholder="YYYY"
value={formData.bottled_at} value={formData.bottled_at}
onChange={(e) => setFormData({ ...formData, bottled_at: e.target.value })} onChange={(e) => setFormData({ ...formData, bottled_at: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
/> />
</div> </div>
</div> </div>
@@ -202,7 +202,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
placeholder="0.00" placeholder="0.00"
value={formData.purchase_price} value={formData.purchase_price}
onChange={(e) => setFormData({ ...formData, purchase_price: e.target.value })} onChange={(e) => setFormData({ ...formData, purchase_price: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 font-bold text-orange-500 text-sm transition-all" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 font-bold text-orange-500 text-sm transition-all"
/> />
</div> </div>
@@ -225,7 +225,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
inputMode="numeric" inputMode="numeric"
value={formData.whiskybase_id} value={formData.whiskybase_id}
onChange={(e) => setFormData({ ...formData, whiskybase_id: e.target.value })} onChange={(e) => setFormData({ ...formData, whiskybase_id: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-300 text-sm font-mono transition-all" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-300 text-sm font-mono transition-all"
/> />
{discoveryResult && ( {discoveryResult && (
<div className="absolute top-full left-0 right-0 z-50 mt-3 p-4 bg-zinc-900 border border-orange-500/30 rounded-2xl shadow-2xl animate-in zoom-in-95 duration-300"> <div className="absolute top-full left-0 right-0 z-50 mt-3 p-4 bg-zinc-900 border border-orange-500/30 rounded-2xl shadow-2xl animate-in zoom-in-95 duration-300">
@@ -263,7 +263,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
placeholder="e.g. Batch 12 or L-Code" placeholder="e.g. Batch 12 or L-Code"
value={formData.batch_info} value={formData.batch_info}
onChange={(e) => setFormData({ ...formData, batch_info: e.target.value })} onChange={(e) => setFormData({ ...formData, batch_info: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -273,7 +273,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
placeholder="e.g. Oloroso Sherry" placeholder="e.g. Oloroso Sherry"
value={formData.cask_type} value={formData.cask_type}
onChange={(e) => setFormData({ ...formData, cask_type: e.target.value })} onChange={(e) => setFormData({ ...formData, cask_type: e.target.value })}
className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-none focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700" className="w-full px-5 py-4 bg-black/40 border border-white/5 rounded-2xl outline-hidden focus:ring-2 focus:ring-orange-600/50 text-zinc-100 text-sm font-bold transition-all placeholder:text-zinc-700"
/> />
</div> </div>
</div> </div>
@@ -296,7 +296,7 @@ export default function EditBottleForm({ bottle, onComplete }: EditBottleFormPro
<button <button
onClick={handleSave} onClick={handleSave}
disabled={isSaving} disabled={isSaving}
className="flex-[2] py-4 bg-orange-600 hover:bg-orange-700 text-white rounded-2xl font-black uppercase tracking-widest text-xs transition-all flex items-center justify-center gap-2 shadow-xl shadow-orange-950/20 disabled:opacity-50" className="flex-2 py-4 bg-orange-600 hover:bg-orange-700 text-white rounded-2xl font-black uppercase tracking-widest text-xs transition-all flex items-center justify-center gap-2 shadow-xl shadow-orange-950/20 disabled:opacity-50"
> >
{isSaving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />} {isSaving ? <Loader2 size={16} className="animate-spin" /> : <Save size={16} />}
{t('bottle.saveChanges')} {t('bottle.saveChanges')}

View File

@@ -108,7 +108,7 @@ export default function FloatingScannerButton({ onImageSelected }: FloatingScann
ease: "easeInOut", ease: "easeInOut",
repeatDelay: 3 repeatDelay: 3
}} }}
className="absolute inset-0 bg-gradient-to-r from-transparent via-white/40 to-transparent skew-x-12 -z-0" className="absolute inset-0 bg-linear-to-r from-transparent via-white/40 to-transparent skew-x-12 z-0"
/> />
)} )}

View File

@@ -56,7 +56,7 @@ export default function HeroBanner() {
}} }}
> >
{/* Overlay gradient */} {/* Overlay gradient */}
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" /> <div className="absolute inset-0 bg-linear-to-t from-black/80 via-black/20 to-transparent" />
{/* Content */} {/* Content */}
<div className="absolute bottom-0 left-0 right-0 p-4"> <div className="absolute bottom-0 left-0 right-0 p-4">

View File

@@ -11,7 +11,7 @@ const LanguageSwitcher = () => {
<button <button
onClick={() => setLocale('de')} onClick={() => setLocale('de')}
className={`p-1.5 rounded-lg transition-all ${locale === 'de' className={`p-1.5 rounded-lg transition-all ${locale === 'de'
? 'bg-orange-950/30 scale-110 shadow-sm shadow-orange-950/20' ? 'bg-orange-950/30 scale-110 shadow-xs shadow-orange-950/20'
: 'opacity-50 hover:opacity-100 grayscale hover:grayscale-0' : 'opacity-50 hover:opacity-100 grayscale hover:grayscale-0'
}`} }`}
title="Deutsch" title="Deutsch"
@@ -21,7 +21,7 @@ const LanguageSwitcher = () => {
<button <button
onClick={() => setLocale('en')} onClick={() => setLocale('en')}
className={`p-1.5 rounded-lg transition-all ${locale === 'en' className={`p-1.5 rounded-lg transition-all ${locale === 'en'
? 'bg-orange-950/30 scale-110 shadow-sm shadow-orange-950/20' ? 'bg-orange-950/30 scale-110 shadow-xs shadow-orange-950/20'
: 'opacity-50 hover:opacity-100 grayscale hover:grayscale-0' : 'opacity-50 hover:opacity-100 grayscale hover:grayscale-0'
}`} }`}
title="English" title="English"

View File

@@ -187,7 +187,7 @@ export default function NativeOCRScanner({
return ( return (
<div className="fixed inset-0 z-50 bg-black"> <div className="fixed inset-0 z-50 bg-black">
{/* Header */} {/* Header */}
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between p-4 bg-gradient-to-b from-black/80 to-transparent"> <div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between p-4 bg-linear-to-b from-black/80 to-transparent">
<div className="flex items-center gap-2 text-white"> <div className="flex items-center gap-2 text-white">
<Zap size={20} className="text-orange-500" /> <Zap size={20} className="text-orange-500" />
<span className="font-bold text-sm">Native OCR</span> <span className="font-bold text-sm">Native OCR</span>
@@ -236,7 +236,7 @@ export default function NativeOCRScanner({
</div> </div>
{/* Detected Text Display */} {/* Detected Text Display */}
<div className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/90 to-transparent"> <div className="absolute bottom-0 left-0 right-0 p-4 bg-linear-to-t from-black/90 to-transparent">
{extractedData.distillery && ( {extractedData.distillery && (
<div className="mb-2 px-3 py-1 bg-orange-600 rounded-full inline-block"> <div className="mb-2 px-3 py-1 bg-orange-600 rounded-full inline-block">
<span className="text-white text-sm font-bold"> <span className="text-white text-sm font-bold">
@@ -247,12 +247,12 @@ export default function NativeOCRScanner({
<div className="flex gap-2 flex-wrap mb-2"> <div className="flex gap-2 flex-wrap mb-2">
{extractedData.abv && ( {extractedData.abv && (
<span className="px-2 py-1 bg-white/20 rounded text-white text-xs"> <span className="px-2 py-1 bg-white/20 rounded-sm text-white text-xs">
{extractedData.abv}% ABV {extractedData.abv}% ABV
</span> </span>
)} )}
{extractedData.age && ( {extractedData.age && (
<span className="px-2 py-1 bg-white/20 rounded text-white text-xs"> <span className="px-2 py-1 bg-white/20 rounded-sm text-white text-xs">
{extractedData.age} Years {extractedData.age} Years
</span> </span>
)} )}

View File

@@ -110,7 +110,7 @@ export default function OnboardingTutorial() {
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-[200] bg-black/90 backdrop-blur-sm flex items-center justify-center p-6" className="fixed inset-0 z-200 bg-black/90 backdrop-blur-xs flex items-center justify-center p-6"
> >
{/* Close button */} {/* Close button */}
<button <button

View File

@@ -76,7 +76,7 @@ export default function PasswordChangeForm() {
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
className="w-full px-4 py-3 pr-12 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent" className="w-full px-4 py-3 pr-12 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-hidden focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/> />
<button <button
type="button" type="button"
@@ -98,7 +98,7 @@ export default function PasswordChangeForm() {
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent" className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-hidden focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/> />
</div> </div>
</div> </div>

View File

@@ -129,7 +129,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Actions Bar */} {/* Actions Bar */}
<div className="bg-zinc-900 rounded-2xl p-6 border border-zinc-800 shadow-sm"> <div className="bg-zinc-900 rounded-2xl p-6 border border-zinc-800 shadow-xs">
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={handleCreate} onClick={handleCreate}
@@ -167,10 +167,10 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
className={`bg-zinc-900 rounded-[32px] p-6 border-2 ${plan.is_active className={`bg-zinc-900 rounded-[32px] p-6 border-2 ${plan.is_active
? 'border-orange-500/30' ? 'border-orange-500/30'
: 'border-zinc-800 opacity-60' : 'border-zinc-800 opacity-60'
} shadow-sm relative`} } shadow-xs relative`}
> >
{!plan.is_active && ( {!plan.is_active && (
<div className="absolute top-4 right-4 px-2 py-1 bg-zinc-800 text-zinc-400 text-[8px] font-bold uppercase tracking-widest rounded"> <div className="absolute top-4 right-4 px-2 py-1 bg-zinc-800 text-zinc-400 text-[8px] font-bold uppercase tracking-widest rounded-sm">
Inactive Inactive
</div> </div>
)} )}
@@ -210,7 +210,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
{/* Edit/Create Modal */} {/* Edit/Create Modal */}
{(editingPlan || isCreating) && ( {(editingPlan || isCreating) && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 z-50"> <div className="fixed inset-0 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 z-50">
<div className="bg-zinc-900 rounded-[32px] p-6 max-w-2xl w-full border border-zinc-800 shadow-2xl"> <div className="bg-zinc-900 rounded-[32px] p-6 max-w-2xl w-full border border-zinc-800 shadow-2xl">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<h3 className="text-2xl font-bold text-white uppercase tracking-tighter"> <h3 className="text-2xl font-bold text-white uppercase tracking-tighter">
@@ -233,7 +233,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="e.g. starter" placeholder="e.g. starter"
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
<div> <div>
@@ -243,7 +243,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
value={formData.display_name} value={formData.display_name}
onChange={(e) => setFormData({ ...formData, display_name: e.target.value })} onChange={(e) => setFormData({ ...formData, display_name: e.target.value })}
placeholder="e.g. Starter" placeholder="e.g. Starter"
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
</div> </div>
@@ -255,7 +255,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
type="number" type="number"
value={formData.monthly_credits} value={formData.monthly_credits}
onChange={(e) => setFormData({ ...formData, monthly_credits: parseInt(e.target.value) || 0 })} onChange={(e) => setFormData({ ...formData, monthly_credits: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
<div> <div>
@@ -265,7 +265,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
step="0.01" step="0.01"
value={formData.price} value={formData.price}
onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })} onChange={(e) => setFormData({ ...formData, price: parseFloat(e.target.value) || 0 })}
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
</div> </div>
@@ -277,7 +277,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Brief description of the plan" placeholder="Brief description of the plan"
rows={3} rows={3}
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
@@ -288,7 +288,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
type="number" type="number"
value={formData.sort_order} value={formData.sort_order}
onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 0 })} onChange={(e) => setFormData({ ...formData, sort_order: parseInt(e.target.value) || 0 })}
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
<div className="flex items-end"> <div className="flex items-end">
@@ -297,7 +297,7 @@ export default function PlanManagementClient({ initialPlans }: PlanManagementCli
type="checkbox" type="checkbox"
checked={formData.is_active} checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })} onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
className="w-5 h-5 rounded border-zinc-700 bg-zinc-800 text-orange-600 focus:ring-orange-600" className="w-5 h-5 rounded-sm border-zinc-700 bg-zinc-800 text-orange-600 focus:ring-orange-600"
/> />
<span className="text-sm font-bold text-white">Active</span> <span className="text-sm font-bold text-white">Active</span>
</label> </label>

View File

@@ -78,7 +78,7 @@ export default function ProfileForm({ initialData }: ProfileFormProps) {
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
placeholder={t('bottle.nameLabel')} placeholder={t('bottle.nameLabel')}
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent" className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-hidden focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/> />
</div> </div>
</div> </div>

View File

@@ -38,7 +38,7 @@ export default function ResultCard({ data, bottleName, image, onShare }: ResultC
className="flex flex-col items-center gap-6 w-full max-w-sm" className="flex flex-col items-center gap-6 w-full max-w-sm"
> >
{/* The Trading Card */} {/* The Trading Card */}
<div className="relative w-full aspect-[3/4] rounded-[32px] overflow-hidden shadow-[0_20px_60px_rgba(0,0,0,0.9)] border border-zinc-800 bg-zinc-950 group"> <div className="relative w-full aspect-3/4 rounded-[32px] overflow-hidden shadow-[0_20px_60px_rgba(0,0,0,0.9)] border border-zinc-800 bg-zinc-950 group">
{/* Bottle Image with Vignette */} {/* Bottle Image with Vignette */}
<div className="absolute inset-0"> <div className="absolute inset-0">
{image ? ( {image ? (
@@ -46,7 +46,7 @@ export default function ResultCard({ data, bottleName, image, onShare }: ResultC
) : ( ) : (
<div className="absolute inset-0 bg-zinc-900 flex items-center justify-center opacity-40 text-[20px] font-bold uppercase tracking-[1em] rotate-90">No Data</div> <div className="absolute inset-0 bg-zinc-900 flex items-center justify-center opacity-40 text-[20px] font-bold uppercase tracking-[1em] rotate-90">No Data</div>
)} )}
<div className="absolute inset-0 bg-gradient-to-t from-zinc-950 via-zinc-950/20 to-transparent opacity-90" /> <div className="absolute inset-0 bg-linear-to-t from-zinc-950 via-zinc-950/20 to-transparent opacity-90" />
</div> </div>
{/* Content Overlay */} {/* Content Overlay */}

View File

@@ -354,12 +354,12 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-[60] bg-zinc-950 flex flex-col h-[100dvh] w-screen overflow-hidden overscroll-none" className="fixed inset-0 z-60 bg-zinc-950 flex flex-col h-dvh w-screen overflow-hidden overscroll-none"
> >
{/* Close Button */} {/* Close Button */}
<button <button
onClick={onClose} onClick={onClose}
className="absolute top-6 right-6 z-[70] p-2 rounded-full bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white transition-colors" className="absolute top-6 right-6 z-70 p-2 rounded-full bg-zinc-900 border border-zinc-800 text-zinc-400 hover:text-white transition-colors"
> >
<X size={24} /> <X size={24} />
</button> </button>
@@ -520,7 +520,7 @@ export default function ScanAndTasteFlow({ isOpen, onClose, imageFile, onBottleS
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
className="absolute inset-0 z-[80] bg-zinc-950/80 backdrop-blur-sm flex flex-col items-center justify-center gap-6" className="absolute inset-0 z-80 bg-zinc-950/80 backdrop-blur-xs flex flex-col items-center justify-center gap-6"
> >
<Loader2 size={48} className="animate-spin text-orange-600" /> <Loader2 size={48} className="animate-spin text-orange-600" />
<h2 className="text-xl font-bold text-zinc-50 uppercase tracking-tight"> <h2 className="text-xl font-bold text-zinc-50 uppercase tracking-tight">

View File

@@ -76,7 +76,7 @@ export default function SessionBottomSheet({ isOpen, onClose }: SessionBottomShe
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
onClick={onClose} onClick={onClose}
className="fixed inset-0 bg-zinc-950/80 backdrop-blur-sm z-[80]" className="fixed inset-0 bg-zinc-950/80 backdrop-blur-xs z-80"
/> />
{/* Sheet */} {/* Sheet */}
@@ -85,7 +85,7 @@ export default function SessionBottomSheet({ isOpen, onClose }: SessionBottomShe
animate={{ y: 0 }} animate={{ y: 0 }}
exit={{ y: '100%' }} exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }} transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="fixed bottom-0 left-0 right-0 bg-[var(--background)] border-t border-white/5 rounded-t-[40px] z-[90] p-8 pb-12 max-h-[85vh] overflow-y-auto shadow-[0_-20px_60px_rgba(0,0,0,0.8)] ring-1 ring-white/5" className="fixed bottom-0 left-0 right-0 bg-(--background) border-t border-white/5 rounded-t-[40px] z-90 p-8 pb-12 max-h-[85vh] overflow-y-auto shadow-[0_-20px_60px_rgba(0,0,0,0.8)] ring-1 ring-white/5"
> >
{/* Drag Handle */} {/* Drag Handle */}
<div className="w-10 h-1 bg-white/10 rounded-full mx-auto mb-8" /> <div className="w-10 h-1 bg-white/10 rounded-full mx-auto mb-8" />
@@ -100,7 +100,7 @@ export default function SessionBottomSheet({ isOpen, onClose }: SessionBottomShe
onChange={(e) => setNewSessionName(e.target.value)} onChange={(e) => setNewSessionName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleCreateSession()} onKeyDown={(e) => e.key === 'Enter' && handleCreateSession()}
placeholder="Neue Session erstellen..." placeholder="Neue Session erstellen..."
className="w-full bg-zinc-900 border border-zinc-800 rounded-2xl py-4 px-6 text-zinc-50 focus:outline-none focus:border-orange-600 transition-colors" className="w-full bg-zinc-900 border border-zinc-800 rounded-2xl py-4 px-6 text-zinc-50 focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
<button <button
onClick={handleCreateSession} onClick={handleCreateSession}

View File

@@ -170,7 +170,7 @@ export default function SessionList() {
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
placeholder={t('session.sessionName')} placeholder={t('session.sessionName')}
className="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:outline-none focus:border-orange-600 transition-colors" className="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
<button <button
type="submit" type="submit"
@@ -201,7 +201,7 @@ export default function SessionList() {
<div <div
key={session.id} key={session.id}
className={`group relative flex items-center justify-between p-5 rounded-[28px] border transition-all duration-500 overflow-hidden ${activeSession?.id === session.id className={`group relative flex items-center justify-between p-5 rounded-[28px] border transition-all duration-500 overflow-hidden ${activeSession?.id === session.id
? 'bg-orange-500/[0.03] border-orange-500/40 shadow-[0_0_40px_rgba(234,88,12,0.1)]' ? 'bg-orange-500/3 border-orange-500/40 shadow-[0_0_40px_rgba(234,88,12,0.1)]'
: 'bg-zinc-950/50 border-white/5 hover:border-white/10' : 'bg-zinc-950/50 border-white/5 hover:border-white/10'
}`} }`}
> >
@@ -289,12 +289,12 @@ export default function SessionList() {
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-top-1"> <div className="flex items-center gap-2 animate-in fade-in slide-in-from-top-1">
<div className="flex -space-x-1.5 overflow-hidden"> <div className="flex -space-x-1.5 overflow-hidden">
{sessions.slice(0, 3).map((s, i) => ( {sessions.slice(0, 3).map((s, i) => (
<div key={s.id} className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[10px] font-bold text-orange-600 shadow-sm"> <div key={s.id} className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[10px] font-bold text-orange-600 shadow-xs">
{s.name[0].toUpperCase()} {s.name[0].toUpperCase()}
</div> </div>
))} ))}
{sessions.length > 3 && ( {sessions.length > 3 && (
<div className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[8px] font-bold text-zinc-500 shadow-sm"> <div className="w-7 h-7 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-[8px] font-bold text-zinc-500 shadow-xs">
+{sessions.length - 3} +{sessions.length - 3}
</div> </div>
)} )}

View File

@@ -67,11 +67,11 @@ export default function SessionTimeline({ tastings, sessionStart, isBlind, isRev
return ( return (
<div key={tasting.id} className="relative group"> <div key={tasting.id} className="relative group">
{/* Dot */} {/* Dot */}
<div className={`absolute -left-[22px] top-4 w-5 h-5 rounded-full border-[3px] border-zinc-950 shadow-sm z-10 flex items-center justify-center ${isSmoky && showDetails ? 'bg-orange-600' : 'bg-zinc-600'}`}> <div className={`absolute -left-[22px] top-4 w-5 h-5 rounded-full border-[3px] border-zinc-950 shadow-xs z-10 flex items-center justify-center ${isSmoky && showDetails ? 'bg-orange-600' : 'bg-zinc-600'}`}>
{isSmoky && showDetails && <Droplets size={8} className="text-white fill-white" />} {isSmoky && showDetails && <Droplets size={8} className="text-white fill-white" />}
</div> </div>
<div className="bg-zinc-900 p-4 rounded-2xl border border-zinc-800 shadow-sm hover:shadow-md transition-shadow group-hover:border-orange-500/30"> <div className="bg-zinc-900 p-4 rounded-2xl border border-zinc-800 shadow-xs hover:shadow-md transition-shadow group-hover:border-orange-500/30">
<div className="flex justify-between items-start gap-3"> <div className="flex justify-between items-start gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
@@ -92,7 +92,7 @@ export default function SessionTimeline({ tastings, sessionStart, isBlind, isRev
{displayName} {displayName}
</Link> </Link>
) : ( ) : (
<div className="text-sm font-bold text-zinc-100 bg-zinc-800/30 blur-[4px] px-2 py-0.5 rounded-md select-none"> <div className="text-sm font-bold text-zinc-100 bg-zinc-800/30 blur-xs px-2 py-0.5 rounded-md select-none">
Unknown Bottle Unknown Bottle
</div> </div>
)} )}

View File

@@ -0,0 +1,102 @@
'use client';
import React from 'react';
// Generic Skeleton components for Suspense fallbacks
export function TastingListSkeleton({ count = 3 }: { count?: number }) {
return (
<div className="space-y-4">
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 animate-pulse"
>
<div className="flex items-start gap-4">
<div className="w-12 h-12 bg-zinc-800 rounded-xl shrink-0" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-zinc-800 rounded w-3/4" />
<div className="h-3 bg-zinc-800 rounded w-1/2" />
<div className="h-3 bg-zinc-800 rounded w-1/4" />
</div>
<div className="w-10 h-10 bg-zinc-800 rounded-xl" />
</div>
</div>
))}
</div>
);
}
export function BottleDetailsSkeleton() {
return (
<div className="space-y-6 animate-pulse">
{/* Image skeleton */}
<div className="aspect-square bg-zinc-900 rounded-3xl" />
{/* Title skeleton */}
<div className="space-y-2">
<div className="h-8 bg-zinc-800 rounded w-3/4" />
<div className="h-5 bg-zinc-800 rounded w-1/2" />
</div>
{/* Badges skeleton */}
<div className="flex gap-2">
<div className="h-8 w-20 bg-zinc-800 rounded-xl" />
<div className="h-8 w-16 bg-zinc-800 rounded-xl" />
<div className="h-8 w-24 bg-zinc-800 rounded-xl" />
</div>
{/* Stats skeleton */}
<div className="grid grid-cols-2 gap-4">
<div className="h-20 bg-zinc-900 rounded-2xl" />
<div className="h-20 bg-zinc-900 rounded-2xl" />
</div>
</div>
);
}
export function ChartSkeleton({ height = 200 }: { height?: number }) {
return (
<div
className="bg-zinc-900 border border-zinc-800 rounded-2xl animate-pulse flex items-center justify-center"
style={{ height }}
>
<div className="text-zinc-700 text-sm">Loading chart...</div>
</div>
);
}
export function StatsCardSkeleton() {
return (
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4 animate-pulse">
<div className="h-3 bg-zinc-800 rounded w-1/2 mb-2" />
<div className="h-8 bg-zinc-800 rounded w-1/3" />
</div>
);
}
export function SessionTimelineSkeleton() {
return (
<div className="space-y-4 animate-pulse">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center gap-4">
<div className="w-3 h-3 bg-zinc-700 rounded-full" />
<div className="flex-1 h-16 bg-zinc-900 border border-zinc-800 rounded-xl" />
</div>
))}
</div>
);
}
export function GridSkeleton({ count = 6 }: { count?: number }) {
return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className="aspect-[3/4] bg-zinc-900 border border-zinc-800 rounded-2xl animate-pulse"
/>
))}
</div>
);
}

View File

@@ -56,7 +56,7 @@ export default function SplitCard({ split, isParticipant, onSelect, showChevron
<Package size={10} /> <Package size={10} />
{split.amountCl}cl {split.amountCl}cl
</span> </span>
<span className={`px-1.5 py-0.5 rounded bg-white/5 border border-white/5 ${split.status === 'SHIPPED' ? 'text-green-500' : 'text-zinc-400'}`}> <span className={`px-1.5 py-0.5 rounded-sm bg-white/5 border border-white/5 ${split.status === 'SHIPPED' ? 'text-green-500' : 'text-zinc-400'}`}>
{statusLabels[split.status || ''] || split.status} {statusLabels[split.status || ''] || split.status}
</span> </span>
</> </>

View File

@@ -75,23 +75,23 @@ export default function SplitProgressBar({
{showLabels && ( {showLabels && (
<div className="flex flex-wrap gap-3 text-[10px] font-bold"> <div className="flex flex-wrap gap-3 text-[10px] font-bold">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm bg-zinc-600" /> <div className="w-2.5 h-2.5 rounded-xs bg-zinc-600" />
<span className="text-zinc-500">Host ({hostShare}cl)</span> <span className="text-zinc-500">Host ({hostShare}cl)</span>
</div> </div>
{taken > 0 && ( {taken > 0 && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm bg-orange-600" /> <div className="w-2.5 h-2.5 rounded-xs bg-orange-600" />
<span className="text-zinc-500">Vergeben ({taken}cl)</span> <span className="text-zinc-500">Vergeben ({taken}cl)</span>
</div> </div>
)} )}
{reserved > 0 && ( {reserved > 0 && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm bg-yellow-500" /> <div className="w-2.5 h-2.5 rounded-xs bg-yellow-500" />
<span className="text-zinc-500">Reserviert ({reserved}cl)</span> <span className="text-zinc-500">Reserviert ({reserved}cl)</span>
</div> </div>
)} )}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-2.5 h-2.5 rounded-sm bg-green-500" /> <div className="w-2.5 h-2.5 rounded-xs bg-green-500" />
<span className="text-zinc-500">Verfügbar ({available}cl)</span> <span className="text-zinc-500">Verfügbar ({available}cl)</span>
</div> </div>
</div> </div>

View File

@@ -90,7 +90,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="Tag suchen oder hinzufügen..." placeholder="Tag suchen oder hinzufügen..."
className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl text-[11px] font-medium focus:ring-1 focus:ring-orange-600/50 focus:border-orange-600/50 outline-none transition-all text-zinc-200 placeholder:text-zinc-600" className="w-full pl-10 pr-4 py-3 bg-zinc-950 border border-zinc-800 rounded-2xl text-[11px] font-medium focus:ring-1 focus:ring-orange-600/50 focus:border-orange-600/50 outline-hidden transition-all text-zinc-200 placeholder:text-zinc-600"
/> />
{isCreating && ( {isCreating && (
<Loader2 className="absolute right-3.5 animate-spin text-orange-600" size={14} /> <Loader2 className="absolute right-3.5 animate-spin text-orange-600" size={14} />
@@ -150,7 +150,7 @@ export default function TagSelector({ category, selectedTagIds, onToggleTag, lab
key={tag.id} key={tag.id}
type="button" type="button"
onClick={() => onToggleTag(tag.id)} onClick={() => onToggleTag(tag.id)}
className="px-3 py-1.5 rounded-xl bg-orange-950/20 text-orange-500 text-[10px] font-black uppercase tracking-tight hover:bg-orange-600 hover:text-white transition-all border border-orange-600/20 flex items-center gap-1.5 shadow-sm" className="px-3 py-1.5 rounded-xl bg-orange-950/20 text-orange-500 text-[10px] font-black uppercase tracking-tight hover:bg-orange-600 hover:text-white transition-all border border-orange-600/20 flex items-center gap-1.5 shadow-xs"
> >
<Sparkles size={10} /> <Sparkles size={10} />
{tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name} {tag.is_system_default ? t(`aroma.${tag.name}`) : tag.name}

View File

@@ -376,7 +376,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleName} value={bottleName}
onChange={(e) => setBottleName(e.target.value)} onChange={(e) => setBottleName(e.target.value)}
placeholder="e.g. 12 Year Old" placeholder="e.g. 12 Year Old"
className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-all ${!checkConfidence('name') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800' className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-all ${!checkConfidence('name') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800'
}`} }`}
/> />
</div> </div>
@@ -398,7 +398,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleDistillery} value={bottleDistillery}
onChange={(e) => setBottleDistillery(e.target.value)} onChange={(e) => setBottleDistillery(e.target.value)}
placeholder="e.g. Lagavulin" placeholder="e.g. Lagavulin"
className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-all ${!checkConfidence('distillery') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800' className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-all ${!checkConfidence('distillery') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800'
}`} }`}
/> />
</div> </div>
@@ -420,7 +420,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleAbv} value={bottleAbv}
onChange={(e) => setBottleAbv(e.target.value)} onChange={(e) => setBottleAbv(e.target.value)}
placeholder="43.0" placeholder="43.0"
className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-all ${!checkConfidence('abv') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800' className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-all ${!checkConfidence('abv') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800'
}`} }`}
/> />
</div> </div>
@@ -439,7 +439,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleAge} value={bottleAge}
onChange={(e) => setBottleAge(e.target.value)} onChange={(e) => setBottleAge(e.target.value)}
placeholder="12" placeholder="12"
className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-all ${!checkConfidence('age') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800' className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-all ${!checkConfidence('age') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800'
}`} }`}
/> />
</div> </div>
@@ -455,7 +455,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleCategory} value={bottleCategory}
onChange={(e) => setBottleCategory(e.target.value)} onChange={(e) => setBottleCategory(e.target.value)}
placeholder="e.g. Single Malt" placeholder="e.g. Single Malt"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors" className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
</div> </div>
{/* Cask Type */} {/* Cask Type */}
@@ -475,7 +475,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleCaskType} value={bottleCaskType}
onChange={(e) => setBottleCaskType(e.target.value)} onChange={(e) => setBottleCaskType(e.target.value)}
placeholder="e.g. Oloroso Sherry Cask" placeholder="e.g. Oloroso Sherry Cask"
className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-all ${!checkConfidence('cask_type') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800' className={`w-full bg-zinc-950 border rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-all ${!checkConfidence('cask_type') ? 'border-yellow-600/50 shadow-[0_0_10px_rgba(202,138,4,0.1)]' : 'border-zinc-800'
}`} }`}
/> />
</div> </div>
@@ -492,7 +492,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleVintage} value={bottleVintage}
onChange={(e) => setBottleVintage(e.target.value)} onChange={(e) => setBottleVintage(e.target.value)}
placeholder="e.g. 2007" placeholder="e.g. 2007"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors" className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
</div> </div>
@@ -506,7 +506,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleBottler} value={bottleBottler}
onChange={(e) => setBottleBottler(e.target.value)} onChange={(e) => setBottleBottler(e.target.value)}
placeholder="e.g. Independent Bottler" placeholder="e.g. Independent Bottler"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors" className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
</div> </div>
@@ -520,7 +520,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleDistilledAt} value={bottleDistilledAt}
onChange={(e) => setBottleDistilledAt(e.target.value)} onChange={(e) => setBottleDistilledAt(e.target.value)}
placeholder="e.g. 2007" placeholder="e.g. 2007"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors" className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
</div> </div>
@@ -534,7 +534,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleBottledAt} value={bottleBottledAt}
onChange={(e) => setBottleBottledAt(e.target.value)} onChange={(e) => setBottleBottledAt(e.target.value)}
placeholder="e.g. 2024" placeholder="e.g. 2024"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors" className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
</div> </div>
@@ -548,7 +548,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleBatchInfo} value={bottleBatchInfo}
onChange={(e) => setBottleBatchInfo(e.target.value)} onChange={(e) => setBottleBatchInfo(e.target.value)}
placeholder="e.g. Oloroso Sherry Cask" placeholder="e.g. Oloroso Sherry Cask"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-orange-600 transition-colors" className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
</div> </div>
@@ -562,7 +562,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={bottleCode} value={bottleCode}
onChange={(e) => setBottleCode(e.target.value)} onChange={(e) => setBottleCode(e.target.value)}
placeholder="e.g. WB271235" placeholder="e.g. WB271235"
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 font-mono focus:outline-none focus:border-orange-600 transition-colors" className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-700 font-mono focus:outline-hidden focus:border-orange-600 transition-colors"
/> />
</div> </div>
@@ -575,7 +575,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
<select <select
value={status} value={status}
onChange={(e) => setStatus(e.target.value)} onChange={(e) => setStatus(e.target.value)}
className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-orange-600 transition-colors" className="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-hidden focus:border-orange-600 transition-colors"
> >
<option value="sealed">Versiegelt</option> <option value="sealed">Versiegelt</option>
<option value="open">Offen</option> <option value="open">Offen</option>
@@ -672,7 +672,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={guessAbv} value={guessAbv}
onChange={(e) => setGuessAbv(e.target.value)} onChange={(e) => setGuessAbv(e.target.value)}
placeholder="z.B. 46.3" placeholder="z.B. 46.3"
className="w-full bg-black/40 border border-purple-500/20 rounded-2xl px-5 py-3 text-sm text-white focus:border-purple-500/50 outline-none transition-all" className="w-full bg-black/40 border border-purple-500/20 rounded-2xl px-5 py-3 text-sm text-white focus:border-purple-500/50 outline-hidden transition-all"
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -682,7 +682,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={guessAge} value={guessAge}
onChange={(e) => setGuessAge(e.target.value)} onChange={(e) => setGuessAge(e.target.value)}
placeholder="z.B. 12" placeholder="z.B. 12"
className="w-full bg-black/40 border border-purple-500/20 rounded-2xl px-5 py-3 text-sm text-white focus:border-purple-500/50 outline-none transition-all" className="w-full bg-black/40 border border-purple-500/20 rounded-2xl px-5 py-3 text-sm text-white focus:border-purple-500/50 outline-hidden transition-all"
/> />
</div> </div>
<div className="md:col-span-2 space-y-2"> <div className="md:col-span-2 space-y-2">
@@ -692,7 +692,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
value={guessRegion} value={guessRegion}
onChange={(e) => setGuessRegion(e.target.value)} onChange={(e) => setGuessRegion(e.target.value)}
placeholder="z.B. Islay / Lagavulin" placeholder="z.B. Islay / Lagavulin"
className="w-full bg-black/40 border border-purple-500/20 rounded-2xl px-5 py-3 text-sm text-white focus:border-purple-500/50 outline-none transition-all" className="w-full bg-black/40 border border-purple-500/20 rounded-2xl px-5 py-3 text-sm text-white focus:border-purple-500/50 outline-hidden transition-all"
/> />
</div> </div>
</div> </div>
@@ -729,7 +729,7 @@ export default function TastingEditor({ bottleMetadata, image, onSave, onOpenSes
</div> </div>
{/* Fixed/Sticky Footer for Save Action */} {/* Fixed/Sticky Footer for Save Action */}
<div className="w-full p-6 bg-gradient-to-t from-zinc-950 via-zinc-950/95 to-transparent border-t border-white/5 shrink-0 z-20"> <div className="w-full p-6 bg-linear-to-t from-zinc-950 via-zinc-950/95 to-transparent border-t border-white/5 shrink-0 z-20">
<div className="max-w-2xl mx-auto"> <div className="max-w-2xl mx-auto">
<button <button
onClick={handleInternalSave} onClick={handleInternalSave}

View File

@@ -161,7 +161,7 @@ export default function TastingFormBody({
onChange={(e) => setNose(e.target.value)} onChange={(e) => setNose(e.target.value)}
placeholder={t('tasting.notesPlaceholder')} placeholder={t('tasting.notesPlaceholder')}
rows={2} rows={2}
className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-700" className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-hidden resize-none transition-all text-zinc-200 placeholder:text-zinc-700"
/> />
</div> </div>
</div> </div>
@@ -213,7 +213,7 @@ export default function TastingFormBody({
onChange={(e) => setPalate(e.target.value)} onChange={(e) => setPalate(e.target.value)}
placeholder={t('tasting.notesPlaceholder')} placeholder={t('tasting.notesPlaceholder')}
rows={2} rows={2}
className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-700" className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-hidden resize-none transition-all text-zinc-200 placeholder:text-zinc-700"
/> />
</div> </div>
</div> </div>
@@ -281,7 +281,7 @@ export default function TastingFormBody({
onChange={(e) => setFinish(e.target.value)} onChange={(e) => setFinish(e.target.value)}
placeholder={t('tasting.notesPlaceholder')} placeholder={t('tasting.notesPlaceholder')}
rows={2} rows={2}
className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-none resize-none transition-all text-zinc-200 placeholder:text-zinc-700" className="w-full p-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-sm focus:ring-1 focus:ring-orange-600 outline-hidden resize-none transition-all text-zinc-200 placeholder:text-zinc-700"
/> />
</div> </div>
</div> </div>

View File

@@ -179,7 +179,7 @@ export default function TastingHub({ isOpen, onClose }: TastingHubProps) {
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
onClick={onClose} onClick={onClose}
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[60]" className="fixed inset-0 bg-black/80 backdrop-blur-xs z-60"
/> />
{/* Content */} {/* Content */}
@@ -188,7 +188,7 @@ export default function TastingHub({ isOpen, onClose }: TastingHubProps) {
animate={{ y: 0 }} animate={{ y: 0 }}
exit={{ y: '100%' }} exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }} transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="fixed bottom-0 left-0 right-0 h-[85vh] bg-[#09090b] border-t border-white/10 rounded-t-[40px] z-[70] flex flex-col shadow-2xl overflow-hidden" className="fixed bottom-0 left-0 right-0 h-[85vh] bg-[#09090b] border-t border-white/10 rounded-t-[40px] z-70 flex flex-col shadow-2xl overflow-hidden"
> >
{/* Header */} {/* Header */}
<div className="p-8 pb-4 flex items-center justify-between shrink-0"> <div className="p-8 pb-4 flex items-center justify-between shrink-0">
@@ -242,7 +242,7 @@ export default function TastingHub({ isOpen, onClose }: TastingHubProps) {
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
placeholder={t('hub.placeholders.sessionName')} placeholder={t('hub.placeholders.sessionName')}
className="flex-1 bg-black/40 border border-white/5 rounded-2xl px-6 py-4 text-sm font-bold text-white placeholder:text-zinc-700 focus:outline-none focus:border-orange-600 transition-all ring-inset focus:ring-1 focus:ring-orange-600/50" className="flex-1 bg-black/40 border border-white/5 rounded-2xl px-6 py-4 text-sm font-bold text-white placeholder:text-zinc-700 focus:outline-hidden focus:border-orange-600 transition-all ring-inset focus:ring-1 focus:ring-orange-600/50"
/> />
<button <button
type="submit" type="submit"

View File

@@ -149,7 +149,7 @@ export default function TastingList({ initialTastings, currentUserId, bottleId }
{sortedTastings.map((note) => ( {sortedTastings.map((note) => (
<div <div
key={note.id} key={note.id}
className="bg-white dark:bg-zinc-900 p-6 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-sm space-y-4 hover:border-amber-500/30 transition-all hover:shadow-md group" className="bg-white dark:bg-zinc-900 p-6 rounded-3xl border border-zinc-200 dark:border-zinc-800 shadow-xs space-y-4 hover:border-amber-500/30 transition-all hover:shadow-md group"
> >
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4"> <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="flex flex-wrap items-center gap-2 sm:gap-3"> <div className="flex flex-wrap items-center gap-2 sm:gap-3">
@@ -192,7 +192,7 @@ export default function TastingList({ initialTastings, currentUserId, bottleId }
<button <button
onClick={() => note.id && note.bottle_id && handleDelete(note.id, note.bottle_id)} onClick={() => note.id && note.bottle_id && handleDelete(note.id, note.bottle_id)}
disabled={!!isDeleting} disabled={!!isDeleting}
className="px-3 py-1.5 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 rounded-xl transition-all disabled:opacity-50 flex items-center gap-2 border border-red-100 dark:border-red-900/30 font-black text-[10px] uppercase tracking-widest shadow-sm hover:bg-red-600 hover:text-white dark:hover:bg-red-600 dark:hover:text-white" className="px-3 py-1.5 text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 rounded-xl transition-all disabled:opacity-50 flex items-center gap-2 border border-red-100 dark:border-red-900/30 font-black text-[10px] uppercase tracking-widest shadow-xs hover:bg-red-600 hover:text-white dark:hover:bg-red-600 dark:hover:text-white"
title="Tasting löschen" title="Tasting löschen"
> >
{isDeleting === note.id ? ( {isDeleting === note.id ? (

View File

@@ -305,7 +305,7 @@ export default function TastingNoteForm({ bottleId, pendingBottleId, sessionId,
)} )}
{/* Sticky Save Button Container */} {/* Sticky Save Button Container */}
<div className="sticky bottom-0 -mx-6 px-6 py-4 bg-gradient-to-t from-zinc-950 via-zinc-950/90 to-transparent z-10"> <div className="sticky bottom-0 -mx-6 px-6 py-4 bg-linear-to-t from-zinc-950 via-zinc-950/90 to-transparent z-10">
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading}

View File

@@ -274,7 +274,7 @@ export default function UploadQueue() {
if (totalInQueue === 0) return null; if (totalInQueue === 0) return null;
return ( return (
<div className="fixed bottom-24 right-6 md:bottom-6 md:right-6 z-[100] flex flex-col items-end gap-3 translate-y-0"> <div className="fixed bottom-24 right-6 md:bottom-6 md:right-6 z-100 flex flex-col items-end gap-3 translate-y-0">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{!isCollapsed ? ( {!isCollapsed ? (
<motion.div <motion.div

View File

@@ -166,7 +166,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Search Bar */} {/* Search Bar */}
<div className="bg-zinc-900 rounded-[32px] p-6 border border-zinc-800 shadow-sm"> <div className="bg-zinc-900 rounded-[32px] p-6 border border-zinc-800 shadow-xs">
<div className="relative"> <div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" size={20} /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" size={20} />
<input <input
@@ -174,13 +174,13 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
placeholder="Search users by email or username..." placeholder="Search users by email or username..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-3 bg-zinc-800 border border-zinc-700 rounded-2xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full pl-10 pr-4 py-3 bg-zinc-800 border border-zinc-700 rounded-2xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
</div> </div>
{/* User Table */} {/* User Table */}
<div className="bg-zinc-900 rounded-[32px] p-6 border border-zinc-800 shadow-sm overflow-hidden"> <div className="bg-zinc-900 rounded-[32px] p-6 border border-zinc-800 shadow-xs overflow-hidden">
<h2 className="text-xl font-bold text-white uppercase tracking-tighter mb-6">Users ({filteredUsers.length})</h2> <h2 className="text-xl font-bold text-white uppercase tracking-tighter mb-6">Users ({filteredUsers.length})</h2>
<div className="overflow-x-auto -mx-6"> <div className="overflow-x-auto -mx-6">
<table className="w-full"> <table className="w-full">
@@ -235,7 +235,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
{/* Edit Modal */} {/* Edit Modal */}
{editingUser && ( {editingUser && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 z-50"> <div className="fixed inset-0 bg-black/80 backdrop-blur-xs flex items-center justify-center p-4 z-50">
<div className="bg-zinc-900 rounded-[32px] p-8 max-w-2xl w-full max-h-[90vh] overflow-y-auto border border-zinc-800 shadow-2xl space-y-8"> <div className="bg-zinc-900 rounded-[32px] p-8 max-w-2xl w-full max-h-[90vh] overflow-y-auto border border-zinc-800 shadow-2xl space-y-8">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@@ -278,7 +278,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
value={creditAmount} value={creditAmount}
onChange={(e) => setCreditAmount(e.target.value)} onChange={(e) => setCreditAmount(e.target.value)}
placeholder="e.g. 100 or -50" placeholder="e.g. 100 or -50"
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
<div> <div>
@@ -288,7 +288,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
value={reason} value={reason}
onChange={(e) => setReason(e.target.value)} onChange={(e) => setReason(e.target.value)}
placeholder="e.g. Monthly bonus" placeholder="e.g. Monthly bonus"
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
</div> </div>
@@ -319,7 +319,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
<select <select
value={selectedPlan} value={selectedPlan}
onChange={(e) => setSelectedPlan(e.target.value)} onChange={(e) => setSelectedPlan(e.target.value)}
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white appearance-none" className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white appearance-none"
> >
<option value="">Select a plan...</option> <option value="">Select a plan...</option>
{plans.map(plan => ( {plans.map(plan => (
@@ -352,7 +352,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
value={dailyLimit} value={dailyLimit}
onChange={(e) => setDailyLimit(e.target.value)} onChange={(e) => setDailyLimit(e.target.value)}
placeholder="Global (80)" placeholder="Global (80)"
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
<div> <div>
@@ -361,7 +361,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
type="number" type="number"
value={googleCost} value={googleCost}
onChange={(e) => setGoogleCost(e.target.value)} onChange={(e) => setGoogleCost(e.target.value)}
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
<div> <div>
@@ -370,7 +370,7 @@ export default function UserManagementClient({ initialUsers, plans }: UserManage
type="number" type="number"
value={geminiCost} value={geminiCost}
onChange={(e) => setGeminiCost(e.target.value)} onChange={(e) => setGeminiCost(e.target.value)}
className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-orange-600/50 text-white" className="w-full px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-xl text-sm focus:outline-hidden focus:ring-2 focus:ring-orange-600/50 text-white"
/> />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,70 @@
'use client';
import { useOptimistic, useTransition } from 'react';
/**
* Hook for optimistic updates with automatic rollback on error.
* Uses React 19's useOptimistic + startTransition pattern.
*
* @example
* const { optimisticData, isPending, mutate } = useOptimisticMutation(
* tastings,
* async (newTasting) => await saveTasting(newTasting)
* );
*/
export function useOptimisticMutation<T, TInput>(
initialData: T[],
mutationFn: (input: TInput) => Promise<{ success: boolean; error?: string }>
) {
const [isPending, startTransition] = useTransition();
const [optimisticData, addOptimistic] = useOptimistic<T[], TInput>(
initialData,
(currentData, newItem) => [...currentData, newItem as unknown as T]
);
const mutate = async (input: TInput, optimisticValue: T) => {
startTransition(async () => {
// Immediately show optimistic update
addOptimistic(input);
// Perform actual mutation
const result = await mutationFn(input);
if (!result.success) {
console.error('[OptimisticMutation] Failed:', result.error);
// Note: React will automatically rollback on error
}
});
};
return {
optimisticData,
isPending,
mutate,
};
}
/**
* Simple optimistic state for single values (like ratings).
*/
export function useOptimisticValue<T>(
serverValue: T,
updateFn: (value: T) => Promise<{ success: boolean }>
) {
const [isPending, startTransition] = useTransition();
const [optimisticValue, setOptimistic] = useOptimistic(serverValue);
const setValue = (value: T) => {
startTransition(async () => {
setOptimistic(value);
await updateFn(value);
});
};
return {
value: optimisticValue,
isPending,
setValue,
};
}

View File

@@ -18,19 +18,28 @@ const translations: Record<Locale, TranslationKeys> = { de, en };
const I18nContext = createContext<I18nContextType | undefined>(undefined); const I18nContext = createContext<I18nContextType | undefined>(undefined);
export const I18nProvider = ({ children }: { children: ReactNode }) => { export const I18nProvider = ({ children }: { children: ReactNode }) => {
const [locale, setLocaleState] = useState<Locale>('de'); const [locale, setLocaleState] = useState<Locale>('en');
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => { useEffect(() => {
// Only run on client side
if (typeof window === 'undefined') return;
// Check for saved preference first
const savedLocale = localStorage.getItem('locale') as Locale; const savedLocale = localStorage.getItem('locale') as Locale;
if (savedLocale && (savedLocale === 'de' || savedLocale === 'en')) { if (savedLocale && (savedLocale === 'de' || savedLocale === 'en')) {
setLocaleState(savedLocale); setLocaleState(savedLocale);
} else { } else {
// Try to detect browser language // Auto-detect from browser: default to English, switch to German if detected
const browserLang = navigator.language.split('-')[0]; const browserLang = navigator.language?.toLowerCase() || 'en';
if (browserLang === 'en') { if (browserLang.startsWith('de')) {
setLocaleState('en'); setLocaleState('de');
localStorage.setItem('locale', 'de');
} else {
localStorage.setItem('locale', 'en');
} }
} }
setIsInitialized(true);
}, []); }, []);
const setLocale = (newLocale: Locale) => { const setLocale = (newLocale: Locale) => {

View File

@@ -4,15 +4,19 @@ import type { SupabaseClient } from '@supabase/supabase-js';
let supabaseClient: SupabaseClient | null = null; let supabaseClient: SupabaseClient | null = null;
export function createClient() { export function createClient() {
if (supabaseClient) return supabaseClient; if (typeof window === 'undefined') {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; return createBrowserClient(supabaseUrl, supabaseAnonKey);
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Supabase URL and Anon Key must be defined');
} }
supabaseClient = createBrowserClient(supabaseUrl, supabaseAnonKey); // Singleton for client-side to prevent multiple instances
return supabaseClient; // Use window object to persist across module reloads in dev
if (!(window as any).supabase) {
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
(window as any).supabase = createBrowserClient(supabaseUrl, supabaseAnonKey);
}
return (window as any).supabase as SupabaseClient;
} }

View File

@@ -52,8 +52,6 @@ export async function middleware(request: NextRequest) {
path.startsWith("/_next"); // Static assets path.startsWith("/_next"); // Static assets
// 2. Specialized Logic for /splits // 2. Specialized Logic for /splits
// - Public: /splits/[slug]
// - Protected: /splits/create, /splits/manage
const isSplitsPublic = const isSplitsPublic =
path.startsWith("/splits/") && path.startsWith("/splits/") &&
!path.startsWith("/splits/create") && !path.startsWith("/splits/create") &&
@@ -75,11 +73,6 @@ export async function middleware(request: NextRequest) {
return NextResponse.redirect(redirectUrl); return NextResponse.redirect(redirectUrl);
} }
// 4. Admin Protection (Optional Layer in Middleware, but Page also checks)
// We rely on Page component for Admin role check to avoid DB hit in middleware if possible,
// OR we can trust getUser() + Page logic.
// Middleware mainly ensures *Authentication*. Authorization is Page level.
return response; return response;
} }

View File

@@ -1,53 +0,0 @@
import { createServerClient, type CookieOptions } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';
export async function proxy(request: NextRequest) {
let response = NextResponse.next({
request: {
headers: request.headers,
},
});
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet: { name: string; value: string; options: CookieOptions }[]) {
cookiesToSet.forEach(({ name, value, options }) =>
request.cookies.set(name, value)
);
response = NextResponse.next({
request: {
headers: request.headers,
},
});
cookiesToSet.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options)
);
},
},
}
);
const { data: { user } } = await supabase.auth.getUser();
const url = new URL(request.url);
const isStatic = url.pathname.startsWith('/_next') || url.pathname.includes('/icon-') || url.pathname === '/favicon.ico';
if (!isStatic) {
const status = user ? `User:${user.id.slice(0, 8)}` : 'No Session';
console.log(`[Proxy] ${request.method} ${url.pathname} | ${status}`);
}
return response;
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico).*)',
],
};

View File

@@ -0,0 +1,191 @@
'use server';
import { Mistral } from '@mistralai/mistralai';
import { getSystemPrompt } from '@/lib/ai-prompts';
import { BottleMetadataSchema, AnalysisResponse, BottleMetadata } from '@/types/whisky';
import { createClient } from '@/lib/supabase/server';
import { createHash } from 'crypto';
import { trackApiUsage } from './track-api-usage';
import { checkCreditBalance, deductCredits } from './credit-service';
// WICHTIG: Wir akzeptieren jetzt FormData statt Strings
export async function analyzeBottleMistral(input: any): Promise<AnalysisResponse & { search_string?: string }> {
if (!process.env.MISTRAL_API_KEY) {
return { success: false, error: 'MISTRAL_API_KEY is not configured.' };
}
let supabase;
try {
// Helper to get value from either FormData or POJO
const getValue = (obj: any, key: string): any => {
if (obj && typeof obj.get === 'function') return obj.get(key);
if (obj && typeof obj[key] !== 'undefined') return obj[key];
return null;
};
// 1. Daten extrahieren
const file = getValue(input, 'file') as File;
const tagsString = getValue(input, 'tags') as string;
const locale = getValue(input, 'locale') || 'de';
if (!file) {
return { success: false, error: 'Kein Bild empfangen.' };
}
const tags = tagsString ? (typeof tagsString === 'string' ? JSON.parse(tagsString) : tagsString) : [];
// 2. Auth & Credits
supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert oder Session abgelaufen.' };
}
const userId = user.id;
const creditCheck = await checkCreditBalance(userId, 'gemini_ai');
if (!creditCheck.allowed) {
return {
success: false,
error: `Nicht genügend Credits. Benötigt: ${creditCheck.cost}, Verfügbar: ${creditCheck.balance}.`
};
}
// 3. Datei in Buffer umwandeln
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
// 4. Hash für Cache erstellen
const imageHash = createHash('sha256').update(buffer).digest('hex');
// Cache Check
const { data: cachedResult } = await supabase
.from('vision_cache')
.select('result')
.eq('hash', imageHash)
.maybeSingle();
if (cachedResult) {
return {
success: true,
data: cachedResult.result as any,
perf: {
apiDuration: 0,
parseDuration: 0,
uploadSize: buffer.length
}
};
}
// 5. Für Mistral vorbereiten
const client = new Mistral({ apiKey: process.env.MISTRAL_API_KEY });
const base64Data = buffer.toString('base64');
const mimeType = file.type || 'image/webp';
const dataUrl = `data:${mimeType};base64,${base64Data}`;
const uploadSize = buffer.length;
const prompt = getSystemPrompt(tags.length > 0 ? tags.join(', ') : 'Keine Tags verfügbar', locale);
try {
const startApi = performance.now();
const chatResponse = await client.chat.complete({
model: 'mistral-large-latest',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{ type: 'image_url', imageUrl: dataUrl }
]
}
],
responseFormat: { type: 'json_object' },
temperature: 0.1
});
const endApi = performance.now();
const startParse = performance.now();
const rawContent = chatResponse.choices?.[0].message.content;
if (!rawContent) throw new Error("Keine Antwort von Mistral");
let jsonData;
try {
jsonData = JSON.parse(rawContent as string);
} catch (e) {
const cleanedText = (rawContent as string).replace(/```json/g, '').replace(/```/g, '');
jsonData = JSON.parse(cleanedText);
}
if (Array.isArray(jsonData)) jsonData = jsonData[0];
console.log('[Mistral AI] JSON Response:', jsonData);
const searchString = jsonData.search_string;
delete jsonData.search_string;
if (typeof jsonData.abv === 'string') {
// Fix: Global replace to remove all % signs
jsonData.abv = parseFloat(jsonData.abv.replace(/%/g, '').trim());
}
if (jsonData.age) jsonData.age = parseInt(jsonData.age);
if (jsonData.vintage) jsonData.vintage = parseInt(jsonData.vintage);
const validatedData = BottleMetadataSchema.parse(jsonData);
const endParse = performance.now();
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'mistral/mistral-large',
success: true,
provider: 'mistral',
model: 'mistral-large-latest',
responseText: rawContent as string
});
await deductCredits(userId, 'gemini_ai', 'Mistral AI analysis');
await supabase
.from('vision_cache')
.insert({ hash: imageHash, result: validatedData });
return {
success: true,
data: validatedData,
search_string: searchString,
perf: {
apiDuration: endApi - startApi,
parseDuration: endParse - startParse,
uploadSize: uploadSize
},
raw: jsonData
};
} catch (aiError: any) {
console.warn('[MistralAnalysis] AI Analysis failed, providing fallback path:', aiError.message);
await trackApiUsage({
userId: userId,
apiType: 'gemini_ai',
endpoint: 'mistral/mistral-large',
success: false,
errorMessage: aiError.message,
provider: 'mistral',
model: 'mistral-large-latest'
});
return {
success: false,
isAiError: true,
error: aiError.message,
imageHash: imageHash
} as any;
}
} catch (error) {
console.error('Mistral Analysis Global Error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Mistral AI analysis failed.',
};
}
}

View File

@@ -1,37 +0,0 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
// High-contrast zinc scale for better readability
// Original zinc-500 (#71717a) is now brighter for better contrast on dark backgrounds
colors: {
zinc: {
50: '#fafafa',
100: '#f4f4f5',
200: '#e4e4e7',
300: '#d4d4d8',
400: '#a8a8b3', // Brighter (was #a1a1aa)
500: '#8a8a95', // Brighter (was #71717a) - main secondary text
600: '#6b6b75', // Brighter (was #52525b) - subtle text
700: '#4a4a52', // Brighter (was #3f3f46)
800: '#2a2a2e', // Slightly adjusted
900: '#1a1a1e', // Dark background
950: '#0d0d0f', // Darkest
},
},
backgroundImage: {
"gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
"gradient-conic":
"conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
},
},
},
plugins: [],
};
export default config;