feat(frontend): unify editorial UI and DRY form architecture
This commit is contained in:
parent
d4fbc1faf5
commit
693c6a9626
35 changed files with 2600 additions and 1180 deletions
189
docs/frontend-design-cookbook.md
Normal file
189
docs/frontend-design-cookbook.md
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
# Frontend Design Cookbook
|
||||
|
||||
This cookbook defines the visual system for the frontend so every new change extends the existing style instead of inventing a new one.
|
||||
|
||||
## Design intent
|
||||
|
||||
- Core tone: light editorial, calm and information-first.
|
||||
- Product feel: premium personal logbook, not generic SaaS dashboard.
|
||||
- Contrast model: neutral paper and ink do most of the work; accents are restrained.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
- Keep layouts readable first. Aesthetic details support hierarchy, not the other way around.
|
||||
- Use one visual language across the app shell, cards, forms, tables, and actions.
|
||||
- Prefer subtle depth (borders, layered paper, soft shadows) over loud gradients.
|
||||
- Keep motion purposeful and short; always preserve `prefers-reduced-motion` behavior.
|
||||
|
||||
## Typography
|
||||
|
||||
- Display/headings: `Cormorant Infant`.
|
||||
- Body/UI text: `Manrope`.
|
||||
- Use display typography for page titles and section heads only.
|
||||
- Keep paragraph text in body font for legibility.
|
||||
|
||||
## Color system
|
||||
|
||||
Global neutrals are defined in `frontend/src/app.css` using CSS variables.
|
||||
|
||||
- `--background`, `--card`, `--foreground`, `--muted-foreground`, `--border`
|
||||
- `--page-accent` drives route-level emphasis.
|
||||
- `--page-accent-soft` is the low-contrast companion tint.
|
||||
|
||||
### Domain accents (muted and professional)
|
||||
|
||||
- Dashboard: `--accent-dashboard`
|
||||
- Products: `--accent-products`
|
||||
- Routines: `--accent-routines`
|
||||
- Skin: `--accent-skin`
|
||||
- Health labs: `--accent-health-labs`
|
||||
- Health medications: `--accent-health-meds`
|
||||
|
||||
The app shell assigns a domain class per route and maps it to `--page-accent`.
|
||||
|
||||
## Where to use accent color
|
||||
|
||||
Use accent for:
|
||||
|
||||
- active navigation state
|
||||
- important buttons and key badges
|
||||
- focus ring tint and hover border tint
|
||||
- section separators and small status markers
|
||||
|
||||
Do not use accent for:
|
||||
|
||||
- full-page backgrounds
|
||||
- body text
|
||||
- large surfaces that reduce readability
|
||||
|
||||
Guideline: accent should occupy roughly 10-15% of visual area per screen.
|
||||
|
||||
## Layout rules
|
||||
|
||||
- Use the app shell spacing rhythm from `app.css` (`.app-main`, editorial cards).
|
||||
- Keep max content width constrained for readability.
|
||||
- Prefer asymmetry in hero/summary areas, but keep forms and dense data grids regular.
|
||||
|
||||
### Shared page wrappers
|
||||
|
||||
Use these wrappers before introducing route-specific structure:
|
||||
|
||||
- `editorial-page`: standard constrained content width for route pages.
|
||||
- `editorial-hero`: top summary strip for title, subtitle, and primary actions.
|
||||
- `editorial-panel`: primary surface for forms, tables, and ledgers.
|
||||
- `editorial-toolbar`: compact action row under hero copy.
|
||||
- `editorial-backlink`: standard top-left back navigation style.
|
||||
- `editorial-alert`, `editorial-alert--error`, `editorial-alert--success`: feedback banners.
|
||||
|
||||
## Component rules
|
||||
|
||||
- Reuse UI primitives under `frontend/src/lib/components/ui/*`.
|
||||
- Keep primitive APIs stable when changing visual treatment.
|
||||
- Style via tokens and shared classes, not one-off hardcoded colors.
|
||||
- New variants must be documented in this file.
|
||||
|
||||
### Existing shared utility patterns
|
||||
|
||||
These classes are already in use and should be reused:
|
||||
|
||||
- Lists and ledgers: `routine-ledger-row`, `products-mobile-card`, `health-entry-row`
|
||||
- Group headers: `products-section-title`
|
||||
- Table shell: `products-table-shell`
|
||||
- Tabs shell: `products-tabs`, `editorial-tabs`
|
||||
- Health semantic pills: `health-kind-pill*`, `health-flag-pill*`
|
||||
|
||||
## Forms and data views
|
||||
|
||||
- Inputs should remain high-contrast and calm.
|
||||
- Validation/error states should be explicit and never color-only.
|
||||
- Tables and dense lists should prioritize scanning: spacing, row separators, concise metadata.
|
||||
|
||||
### DRY form primitives
|
||||
|
||||
- Use shared form components for repeated native select markup:
|
||||
- `frontend/src/lib/components/forms/SimpleSelect.svelte`
|
||||
- `frontend/src/lib/components/forms/GroupedSelect.svelte`
|
||||
- Use shared checkbox helper for repeated label+hint toggles:
|
||||
- `frontend/src/lib/components/forms/HintCheckbox.svelte`
|
||||
- Use shared input field helper for repeated label+input rows:
|
||||
- `frontend/src/lib/components/forms/LabeledInputField.svelte`
|
||||
- Use shared section card helper for repeated titled form panels:
|
||||
- `frontend/src/lib/components/forms/FormSectionCard.svelte`
|
||||
- Use shared class tokens from:
|
||||
- `frontend/src/lib/components/forms/form-classes.ts`
|
||||
- Prefer passing option labels from route files via `m.*` to keep i18n explicit.
|
||||
|
||||
### Select policy (performance + maintainability)
|
||||
|
||||
- Default to native `<select>` for enum-style fields and simple pickers.
|
||||
- Use `SimpleSelect` / `GroupedSelect` for consistency and to reduce duplication.
|
||||
- Avoid `ui/select` primitives unless search, custom keyboard behavior, or richer popup UX is truly needed.
|
||||
- For grouped option sets (e.g. products by category), use `<optgroup>` via `GroupedSelect`.
|
||||
|
||||
### Chunk hygiene
|
||||
|
||||
- Large forms should be split into focused section components.
|
||||
- Lazy-load heavyweight modal and optional sections where practical.
|
||||
- After structural UI refactors, run `pnpm build` and inspect output for large chunks.
|
||||
- Track and prevent regressions in known hotspots (form-heavy pages and shared interaction primitives).
|
||||
|
||||
## Motion
|
||||
|
||||
- Entry animations: short upward reveal with slight stagger.
|
||||
- Hover interactions: subtle translation or tint only.
|
||||
- Never stack multiple strong animations in the same viewport.
|
||||
|
||||
## Accessibility baseline
|
||||
|
||||
- Keyboard navigation for all interactive elements.
|
||||
- Visible focus states on controls and links.
|
||||
- Respect reduced motion.
|
||||
- Maintain readable contrast for text, borders, and controls.
|
||||
|
||||
## i18n rules
|
||||
|
||||
- Do not add hardcoded UI labels in route components when a message key already exists.
|
||||
- Prefer existing `m.*` keys from paraglide for headings, labels, empty states, and helper text.
|
||||
- If a new label is required, add a new translation key instead of shipping English literals.
|
||||
- Keep fallback display values localized (`m.common_unknown()` over hardcoded `n/a`).
|
||||
- Avoid language-specific toggle text in reusable components unless fully localized.
|
||||
|
||||
## Implementation checklist for new UI work
|
||||
|
||||
1. Pick the route domain and rely on `--page-accent`.
|
||||
2. Use existing typography pair and spacing rhythm.
|
||||
3. Build with primitives first; add route-level wrappers only if needed.
|
||||
4. Validate mobile and desktop layout.
|
||||
5. Run:
|
||||
- `pnpm check`
|
||||
- `pnpm lint`
|
||||
- `pnpm build` (for significant component architecture changes)
|
||||
|
||||
## File touchpoints
|
||||
|
||||
- Core tokens and global look: `frontend/src/app.css`
|
||||
- App shell and route domain mapping: `frontend/src/routes/+layout.svelte`
|
||||
- Route examples using the pattern:
|
||||
- `frontend/src/routes/+page.svelte`
|
||||
- `frontend/src/routes/products/+page.svelte`
|
||||
- `frontend/src/routes/routines/+page.svelte`
|
||||
- `frontend/src/routes/health/lab-results/+page.svelte`
|
||||
- `frontend/src/routes/skin/+page.svelte`
|
||||
- Primitive visuals:
|
||||
- `frontend/src/lib/components/ui/button/button.svelte`
|
||||
- `frontend/src/lib/components/ui/card/card.svelte`
|
||||
- `frontend/src/lib/components/ui/input/input.svelte`
|
||||
- `frontend/src/lib/components/ui/badge/badge.svelte`
|
||||
|
||||
- Shared form DRY helpers:
|
||||
- `frontend/src/lib/components/forms/SimpleSelect.svelte`
|
||||
- `frontend/src/lib/components/forms/GroupedSelect.svelte`
|
||||
- `frontend/src/lib/components/forms/HintCheckbox.svelte`
|
||||
- `frontend/src/lib/components/forms/LabeledInputField.svelte`
|
||||
- `frontend/src/lib/components/forms/FormSectionCard.svelte`
|
||||
- `frontend/src/lib/components/forms/form-classes.ts`
|
||||
|
||||
- i18n message source:
|
||||
- `frontend/src/lib/paraglide/messages/*`
|
||||
|
||||
When introducing new visual patterns, update this cookbook in the same change.
|
||||
|
|
@ -5,26 +5,42 @@
|
|||
/* ── CSS variable definitions (light / dark) ─────────────────────────────── */
|
||||
|
||||
:root {
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(240 10% 3.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(240 10% 3.9%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(240 10% 3.9%);
|
||||
--primary: hsl(240 5.9% 10%);
|
||||
--primary-foreground: hsl(0 0% 98%);
|
||||
--secondary: hsl(240 4.8% 95.9%);
|
||||
--secondary-foreground: hsl(240 5.9% 10%);
|
||||
--muted: hsl(240 4.8% 95.9%);
|
||||
--muted-foreground: hsl(240 3.8% 46.1%);
|
||||
--accent: hsl(240 4.8% 95.9%);
|
||||
--accent-foreground: hsl(240 5.9% 10%);
|
||||
--background: hsl(42 35% 95%);
|
||||
--foreground: hsl(220 24% 14%);
|
||||
--card: hsl(44 32% 96%);
|
||||
--card-foreground: hsl(220 24% 14%);
|
||||
--popover: hsl(44 32% 96%);
|
||||
--popover-foreground: hsl(220 24% 14%);
|
||||
--primary: hsl(15 44% 34%);
|
||||
--primary-foreground: hsl(42 40% 97%);
|
||||
--secondary: hsl(38 24% 91%);
|
||||
--secondary-foreground: hsl(220 20% 20%);
|
||||
--muted: hsl(42 20% 90%);
|
||||
--muted-foreground: hsl(219 12% 39%);
|
||||
--accent: hsl(42 24% 90%);
|
||||
--accent-foreground: hsl(220 24% 14%);
|
||||
--destructive: hsl(0 84.2% 60.2%);
|
||||
--destructive-foreground: hsl(0 0% 98%);
|
||||
--border: hsl(240 5.9% 90%);
|
||||
--input: hsl(240 5.9% 90%);
|
||||
--ring: hsl(240 5.9% 10%);
|
||||
--border: hsl(35 23% 76%);
|
||||
--input: hsl(37 20% 80%);
|
||||
--ring: hsl(15 40% 38%);
|
||||
--radius: 0.5rem;
|
||||
|
||||
--editorial-paper: hsl(48 37% 96%);
|
||||
--editorial-paper-strong: hsl(44 43% 92%);
|
||||
--editorial-ink: hsl(220 23% 14%);
|
||||
--editorial-muted: hsl(219 12% 39%);
|
||||
--editorial-line: hsl(36 24% 74%);
|
||||
|
||||
--accent-dashboard: hsl(13 45% 39%);
|
||||
--accent-products: hsl(95 28% 33%);
|
||||
--accent-routines: hsl(186 27% 33%);
|
||||
--accent-skin: hsl(16 51% 44%);
|
||||
--accent-health-labs: hsl(212 41% 39%);
|
||||
--accent-health-meds: hsl(140 31% 33%);
|
||||
|
||||
--page-accent: var(--accent-dashboard);
|
||||
--page-accent-soft: hsl(24 42% 89%);
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
|
@ -86,4 +102,723 @@
|
|||
body {
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: 'Manrope', 'Segoe UI', sans-serif;
|
||||
background-image:
|
||||
radial-gradient(circle at 8% 4%, hsl(34 48% 90% / 0.62), transparent 36%),
|
||||
linear-gradient(hsl(42 26% 95%), hsl(40 20% 93%));
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
--page-accent: var(--accent-dashboard);
|
||||
--page-accent-soft: hsl(18 40% 89%);
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.domain-dashboard {
|
||||
--page-accent: var(--accent-dashboard);
|
||||
--page-accent-soft: hsl(18 40% 89%);
|
||||
}
|
||||
|
||||
.domain-products {
|
||||
--page-accent: var(--accent-products);
|
||||
--page-accent-soft: hsl(95 28% 89%);
|
||||
}
|
||||
|
||||
.domain-routines {
|
||||
--page-accent: var(--accent-routines);
|
||||
--page-accent-soft: hsl(186 28% 88%);
|
||||
}
|
||||
|
||||
.domain-skin {
|
||||
--page-accent: var(--accent-skin);
|
||||
--page-accent-soft: hsl(20 52% 88%);
|
||||
}
|
||||
|
||||
.domain-health-labs {
|
||||
--page-accent: var(--accent-health-labs);
|
||||
--page-accent-soft: hsl(208 38% 88%);
|
||||
}
|
||||
|
||||
.domain-health-meds {
|
||||
--page-accent: var(--accent-health-meds);
|
||||
--page-accent-soft: hsl(135 28% 88%);
|
||||
}
|
||||
|
||||
.app-mobile-header {
|
||||
border-bottom: 1px solid hsl(35 22% 76% / 0.7);
|
||||
background: linear-gradient(180deg, hsl(44 35% 97%), hsl(44 25% 94%));
|
||||
}
|
||||
|
||||
.app-mobile-title,
|
||||
.app-brand {
|
||||
font-family: 'Cormorant Infant', 'Times New Roman', serif;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.app-icon-button {
|
||||
display: flex;
|
||||
height: 2rem;
|
||||
width: 2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid hsl(34 21% 75%);
|
||||
border-radius: 0.45rem;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.app-icon-button:hover {
|
||||
color: var(--foreground);
|
||||
border-color: var(--page-accent);
|
||||
background: var(--page-accent-soft);
|
||||
}
|
||||
|
||||
.app-sidebar {
|
||||
border-right: 1px solid hsl(36 20% 73% / 0.75);
|
||||
background: linear-gradient(180deg, hsl(44 34% 97%), hsl(42 28% 94%));
|
||||
}
|
||||
|
||||
.app-sidebar a {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.app-sidebar a:hover {
|
||||
border-color: hsl(35 23% 76% / 0.75);
|
||||
}
|
||||
|
||||
.app-sidebar a.bg-accent {
|
||||
border-color: color-mix(in srgb, var(--page-accent) 45%, white);
|
||||
background: color-mix(in srgb, var(--page-accent) 13%, white);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.app-main > div {
|
||||
margin: 0 auto;
|
||||
width: min(1160px, 100%);
|
||||
}
|
||||
|
||||
.app-main h2 {
|
||||
font-family: 'Cormorant Infant', 'Times New Roman', serif;
|
||||
font-size: clamp(1.9rem, 3.3vw, 2.7rem);
|
||||
line-height: 1.02;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.app-main h3 {
|
||||
font-family: 'Cormorant Infant', 'Times New Roman', serif;
|
||||
}
|
||||
|
||||
.editorial-page {
|
||||
width: min(1060px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.editorial-backlink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
color: var(--muted-foreground);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.editorial-backlink:hover {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.editorial-toolbar {
|
||||
margin-top: 0.9rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.editorial-filter-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.editorial-alert {
|
||||
border-radius: 0.7rem;
|
||||
border: 1px solid hsl(34 25% 75% / 0.8);
|
||||
background: hsl(42 36% 93%);
|
||||
padding: 0.72rem 0.85rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.editorial-alert--error {
|
||||
border-color: hsl(3 53% 71%);
|
||||
background: hsl(4 72% 93%);
|
||||
color: hsl(3 62% 34%);
|
||||
}
|
||||
|
||||
.editorial-alert--success {
|
||||
border-color: hsl(132 28% 72%);
|
||||
background: hsl(127 36% 92%);
|
||||
color: hsl(136 48% 26%);
|
||||
}
|
||||
|
||||
.products-table-shell {
|
||||
border: 1px solid hsl(35 24% 74% / 0.85);
|
||||
border-radius: 0.9rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.products-category-row {
|
||||
background: color-mix(in srgb, var(--page-accent) 10%, white);
|
||||
}
|
||||
|
||||
.products-mobile-card {
|
||||
display: block;
|
||||
border: 1px solid hsl(35 21% 76% / 0.85);
|
||||
border-radius: 0.8rem;
|
||||
padding: 0.95rem;
|
||||
}
|
||||
|
||||
.products-section-title {
|
||||
border-bottom: 1px dashed color-mix(in srgb, var(--page-accent) 35%, var(--border));
|
||||
padding-bottom: 0.3rem;
|
||||
padding-top: 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.13em;
|
||||
color: var(--muted-foreground);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.products-sticky-actions {
|
||||
border-color: color-mix(in srgb, var(--page-accent) 25%, var(--border));
|
||||
}
|
||||
|
||||
.products-meta-strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.products-tabs [data-slot='tabs-list'],
|
||||
.editorial-tabs [data-slot='tabs-list'] {
|
||||
border: 1px solid hsl(35 22% 75% / 0.75);
|
||||
background: hsl(40 28% 93%);
|
||||
}
|
||||
|
||||
.routine-ledger-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 1px solid hsl(35 21% 76% / 0.82);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease;
|
||||
}
|
||||
|
||||
.routine-ledger-row:hover {
|
||||
transform: translateX(2px);
|
||||
border-color: color-mix(in srgb, var(--page-accent) 42%, var(--border));
|
||||
background: var(--page-accent-soft);
|
||||
}
|
||||
|
||||
.health-entry-row {
|
||||
border: 1px solid hsl(35 21% 76% / 0.82);
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.8rem 0.9rem;
|
||||
background: linear-gradient(165deg, hsl(44 31% 96%), hsl(42 30% 94%));
|
||||
}
|
||||
|
||||
.health-kind-pill,
|
||||
.health-flag-pill {
|
||||
display: inline-flex;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
padding: 0.26rem 0.62rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.health-kind-pill--prescription,
|
||||
.health-flag-pill--abnormal,
|
||||
.health-flag-pill--high {
|
||||
border-color: hsl(4 54% 70%);
|
||||
background: hsl(5 58% 91%);
|
||||
color: hsl(5 58% 31%);
|
||||
}
|
||||
|
||||
.health-kind-pill--otc,
|
||||
.health-flag-pill--negative {
|
||||
border-color: hsl(206 40% 69%);
|
||||
background: hsl(205 45% 90%);
|
||||
color: hsl(208 53% 29%);
|
||||
}
|
||||
|
||||
.health-kind-pill--supplement,
|
||||
.health-kind-pill--herbal,
|
||||
.health-flag-pill--normal {
|
||||
border-color: hsl(136 27% 67%);
|
||||
background: hsl(132 31% 90%);
|
||||
color: hsl(136 49% 26%);
|
||||
}
|
||||
|
||||
.health-kind-pill--other {
|
||||
border-color: hsl(35 20% 70%);
|
||||
background: hsl(40 22% 89%);
|
||||
color: hsl(28 24% 29%);
|
||||
}
|
||||
|
||||
.health-flag-pill--positive,
|
||||
.health-flag-pill--low {
|
||||
border-color: hsl(33 53% 67%);
|
||||
background: hsl(35 55% 90%);
|
||||
color: hsl(28 55% 30%);
|
||||
}
|
||||
|
||||
[data-slot='card'] {
|
||||
border-color: hsl(35 22% 75% / 0.8);
|
||||
background: linear-gradient(170deg, hsl(44 34% 97%), hsl(41 30% 95%));
|
||||
}
|
||||
|
||||
[data-slot='input'] {
|
||||
border-color: hsl(36 21% 74%);
|
||||
background: hsl(42 28% 96%);
|
||||
}
|
||||
|
||||
[data-slot='input']:focus-visible {
|
||||
border-color: color-mix(in srgb, var(--page-accent) 58%, white);
|
||||
}
|
||||
|
||||
[data-slot='button']:focus-visible,
|
||||
[data-slot='badge']:focus-visible {
|
||||
outline-color: var(--page-accent);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.app-shell {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.editorial-dashboard {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
width: min(1100px, 100%);
|
||||
color: var(--editorial-ink);
|
||||
}
|
||||
|
||||
.editorial-atmosphere {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: -2.5rem -1rem auto;
|
||||
z-index: 0;
|
||||
height: 14rem;
|
||||
border-radius: 2rem;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 18% 34%,
|
||||
color-mix(in srgb, var(--page-accent) 24%, white) 0%,
|
||||
transparent 47%
|
||||
),
|
||||
radial-gradient(circle at 74% 16%, hsl(198 63% 85% / 0.52), transparent 39%),
|
||||
linear-gradient(130deg, hsl(45 48% 94%), hsl(34 38% 91%));
|
||||
filter: saturate(110%);
|
||||
}
|
||||
|
||||
.editorial-hero,
|
||||
.editorial-panel {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(36 26% 74% / 0.8);
|
||||
background: linear-gradient(160deg, hsl(44 40% 95%), var(--editorial-paper));
|
||||
box-shadow:
|
||||
0 24px 48px -34px hsl(219 32% 14% / 0.44),
|
||||
inset 0 1px 0 hsl(0 0% 100% / 0.75);
|
||||
}
|
||||
|
||||
.editorial-hero {
|
||||
margin-bottom: 1.1rem;
|
||||
border-radius: 1.5rem;
|
||||
padding: clamp(1.2rem, 2.6vw, 2rem);
|
||||
}
|
||||
|
||||
.editorial-kicker {
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--editorial-muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.editorial-title {
|
||||
margin: 0;
|
||||
font-family: 'Cormorant Infant', 'Times New Roman', serif;
|
||||
font-size: clamp(2.2rem, 5vw, 3.6rem);
|
||||
font-weight: 600;
|
||||
line-height: 0.95;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.editorial-subtitle {
|
||||
margin-top: 0.66rem;
|
||||
max-width: 48ch;
|
||||
color: var(--editorial-muted);
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.hero-strip {
|
||||
margin-top: 1.3rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.8rem;
|
||||
border-top: 1px dashed color-mix(in srgb, var(--page-accent) 30%, var(--editorial-line));
|
||||
padding-top: 0.9rem;
|
||||
}
|
||||
|
||||
.hero-strip-label {
|
||||
margin: 0;
|
||||
color: var(--editorial-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-strip-value {
|
||||
margin: 0.22rem 0 0;
|
||||
font-family: 'Cormorant Infant', 'Times New Roman', serif;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.editorial-grid {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.editorial-panel {
|
||||
border-radius: 1.2rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
margin-bottom: 0.9rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
border-bottom: 1px solid hsl(36 20% 73% / 0.72);
|
||||
padding-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
margin: 0;
|
||||
font-family: 'Cormorant Infant', 'Times New Roman', serif;
|
||||
font-size: clamp(1.35rem, 2.4vw, 1.7rem);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel-index {
|
||||
margin: 0;
|
||||
color: var(--editorial-muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.panel-action-row {
|
||||
margin-bottom: 0.7rem;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.snapshot-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.snapshot-date {
|
||||
color: var(--editorial-muted);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.state-pill,
|
||||
.routine-pill {
|
||||
display: inline-flex;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
padding: 0.28rem 0.68rem;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.state-pill--excellent {
|
||||
border-color: hsl(145 34% 65%);
|
||||
background: hsl(146 42% 90%);
|
||||
color: hsl(144 48% 26%);
|
||||
}
|
||||
|
||||
.state-pill--good {
|
||||
border-color: hsl(191 44% 68%);
|
||||
background: hsl(190 56% 90%);
|
||||
color: hsl(193 60% 24%);
|
||||
}
|
||||
|
||||
.state-pill--fair {
|
||||
border-color: hsl(40 68% 67%);
|
||||
background: hsl(44 76% 90%);
|
||||
color: hsl(35 63% 30%);
|
||||
}
|
||||
|
||||
.state-pill--poor {
|
||||
border-color: hsl(4 64% 67%);
|
||||
background: hsl(6 72% 89%);
|
||||
color: hsl(8 64% 33%);
|
||||
}
|
||||
|
||||
.concern-cloud {
|
||||
margin-top: 0.92rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.44rem;
|
||||
}
|
||||
|
||||
.concern-chip {
|
||||
border: 1px solid hsl(36 24% 71% / 0.88);
|
||||
border-radius: 0.42rem;
|
||||
background: hsl(42 36% 92%);
|
||||
padding: 0.36rem 0.52rem;
|
||||
color: hsl(220 20% 22%);
|
||||
font-size: 0.81rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.snapshot-notes {
|
||||
margin-top: 0.9rem;
|
||||
border-left: 2px solid hsl(37 34% 66% / 0.8);
|
||||
padding-left: 0.8rem;
|
||||
color: hsl(220 13% 34%);
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.routine-list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.routine-summary-strip {
|
||||
margin-bottom: 0.7rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.routine-summary-chip {
|
||||
border: 1px solid hsl(35 24% 71% / 0.85);
|
||||
border-radius: 999px;
|
||||
padding: 0.22rem 0.62rem;
|
||||
color: var(--editorial-muted);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.panel-action-link,
|
||||
.routine-summary-link {
|
||||
border: 1px solid color-mix(in srgb, var(--page-accent) 38%, var(--editorial-line));
|
||||
border-radius: 999px;
|
||||
padding: 0.24rem 0.64rem;
|
||||
color: var(--page-accent);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.routine-summary-link {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.panel-action-link:hover,
|
||||
.routine-summary-link:hover {
|
||||
background: var(--page-accent-soft);
|
||||
}
|
||||
|
||||
.routine-item + .routine-item {
|
||||
border-top: 1px dashed hsl(36 26% 72% / 0.7);
|
||||
}
|
||||
|
||||
.routine-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.78rem 0;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: transform 140ms ease, color 160ms ease;
|
||||
}
|
||||
|
||||
.routine-main {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.routine-topline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.routine-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
color: var(--editorial-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.routine-note-inline {
|
||||
overflow: hidden;
|
||||
max-width: 38ch;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.routine-link:hover {
|
||||
transform: translateX(4px);
|
||||
color: var(--page-accent);
|
||||
}
|
||||
|
||||
.routine-link:focus-visible {
|
||||
outline: 2px solid var(--page-accent);
|
||||
outline-offset: 3px;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
|
||||
.routine-date {
|
||||
font-size: 0.93rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.routine-pill--am {
|
||||
border-color: hsl(188 43% 66%);
|
||||
background: hsl(188 52% 89%);
|
||||
color: hsl(194 56% 24%);
|
||||
}
|
||||
|
||||
.routine-pill--pm {
|
||||
border-color: hsl(21 58% 67%);
|
||||
background: hsl(23 68% 90%);
|
||||
color: hsl(14 56% 31%);
|
||||
}
|
||||
|
||||
.empty-copy {
|
||||
margin: 0;
|
||||
color: var(--editorial-muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.empty-actions {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.reveal-1,
|
||||
.reveal-2,
|
||||
.reveal-3 {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
animation: editorial-rise 620ms cubic-bezier(0.2, 0.85, 0.24, 1) forwards;
|
||||
}
|
||||
|
||||
.reveal-2 {
|
||||
animation-delay: 90ms;
|
||||
}
|
||||
|
||||
.reveal-3 {
|
||||
animation-delay: 160ms;
|
||||
}
|
||||
|
||||
@keyframes editorial-rise {
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.editorial-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.editorial-title {
|
||||
font-size: 2.05rem;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.state-pill,
|
||||
.routine-pill {
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reveal-1,
|
||||
.reveal-2,
|
||||
.reveal-3 {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.routine-link {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@
|
|||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Infant:wght@500;600;700&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
|
|
|
|||
|
|
@ -5,26 +5,26 @@
|
|||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||
import { Tabs, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { parseProductText, type ProductParseResponse } from '$lib/api';
|
||||
import { baseSelectClass, baseTextareaClass } from '$lib/components/forms/form-classes';
|
||||
import type { ProductParseResponse } from '$lib/api';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Sparkles, X } from 'lucide-svelte';
|
||||
|
||||
let {
|
||||
product,
|
||||
dirty = $bindable(false),
|
||||
saveVersion = 0,
|
||||
showAiTrigger = true,
|
||||
onDirtyChange,
|
||||
computedPriceLabel,
|
||||
computedPricePerUseLabel,
|
||||
computedPriceTierLabel
|
||||
}: {
|
||||
product?: Product;
|
||||
dirty?: boolean;
|
||||
saveVersion?: number;
|
||||
showAiTrigger?: boolean;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
computedPriceLabel?: string;
|
||||
computedPricePerUseLabel?: string;
|
||||
computedPriceTierLabel?: string;
|
||||
|
|
@ -140,10 +140,6 @@
|
|||
{ value: 'false', label: m.common_no() }
|
||||
]);
|
||||
|
||||
function tristateLabel(val: string): string {
|
||||
return val === '' ? m.common_unknown() : val === 'true' ? m.common_yes() : m.common_no();
|
||||
}
|
||||
|
||||
const effectFields = $derived([
|
||||
{ key: 'hydration_immediate' as const, label: m["productForm_effectHydrationImmediate"]() },
|
||||
{ key: 'hydration_long_term' as const, label: m["productForm_effectHydrationLongTerm"]() },
|
||||
|
|
@ -201,6 +197,7 @@
|
|||
aiLoading = true;
|
||||
aiError = '';
|
||||
try {
|
||||
const { parseProductText } = await import('$lib/api');
|
||||
const r = await parseProductText(aiText);
|
||||
applyAiResult(r);
|
||||
aiModalOpen = false;
|
||||
|
|
@ -379,11 +376,9 @@
|
|||
)
|
||||
);
|
||||
|
||||
const textareaClass =
|
||||
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2';
|
||||
const textareaClass = `${baseTextareaClass} focus-visible:ring-offset-2`;
|
||||
|
||||
const selectClass =
|
||||
'h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring';
|
||||
const selectClass = baseSelectClass;
|
||||
|
||||
export function openAiModal() {
|
||||
aiError = '';
|
||||
|
|
@ -466,11 +461,11 @@
|
|||
baselineFingerprint = currentFingerprint;
|
||||
baselineProductId = currentProductId;
|
||||
baselineSaveVersion = saveVersion;
|
||||
dirty = false;
|
||||
onDirtyChange?.(false);
|
||||
return;
|
||||
}
|
||||
|
||||
dirty = currentFingerprint !== baselineFingerprint;
|
||||
onDirtyChange?.(currentFingerprint !== baselineFingerprint);
|
||||
});
|
||||
</script>
|
||||
|
||||
|
|
@ -496,153 +491,51 @@
|
|||
{/if}
|
||||
|
||||
{#if aiModalOpen}
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-50 bg-black/50"
|
||||
onclick={closeAiModal}
|
||||
aria-label={m.common_cancel()}
|
||||
></button>
|
||||
<div class="fixed inset-x-3 bottom-3 top-3 z-50 mx-auto flex max-w-2xl items-center md:inset-x-6 md:inset-y-8">
|
||||
<Card class="max-h-full w-full overflow-hidden">
|
||||
<CardHeader class="border-b border-border">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<CardTitle>{m["productForm_aiPrefill"]()}</CardTitle>
|
||||
<Button type="button" variant="ghost" size="sm" class="h-8 w-8 p-0" onclick={closeAiModal} aria-label={m.common_cancel()}>
|
||||
<X class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3 overflow-y-auto p-4">
|
||||
<p class="text-sm text-muted-foreground">{m["productForm_aiPrefillText"]()}</p>
|
||||
<textarea bind:value={aiText} rows="8" placeholder={m["productForm_pasteText"]()} class={textareaClass}></textarea>
|
||||
{#if aiError}
|
||||
<p class="text-sm text-destructive">{aiError}</p>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onclick={closeAiModal} disabled={aiLoading}>{m.common_cancel()}</Button>
|
||||
<Button type="button" onclick={parseWithAi} disabled={aiLoading || !aiText.trim()}>
|
||||
{#if aiLoading}
|
||||
{m["productForm_parsing"]()}
|
||||
{:else}
|
||||
<Sparkles class="size-4" /> {m["productForm_parseWithAI"]()}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{#await import('$lib/components/ProductFormAiModal.svelte') then mod}
|
||||
{@const AiModal = mod.default}
|
||||
<AiModal
|
||||
open={aiModalOpen}
|
||||
bind:aiText
|
||||
{aiLoading}
|
||||
aiError={aiError}
|
||||
{textareaClass}
|
||||
onClose={closeAiModal}
|
||||
onSubmit={parseWithAi}
|
||||
/>
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
<!-- ── Basic info ──────────────────────────────────────────────────────────── -->
|
||||
<Card class={editSection === 'basic' ? '' : 'hidden'}>
|
||||
<CardHeader><CardTitle>{m["productForm_basicInfo"]()}</CardTitle></CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="name">{m["productForm_name"]()}</Label>
|
||||
<Input id="name" name="name" required placeholder={m["productForm_namePlaceholder"]()} bind:value={name} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="brand">{m["productForm_brand"]()}</Label>
|
||||
<Input id="brand" name="brand" required placeholder={m["productForm_brandPlaceholder"]()} bind:value={brand} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="line_name">{m["productForm_lineName"]()}</Label>
|
||||
<Input id="line_name" name="line_name" placeholder={m["productForm_lineNamePlaceholder"]()} bind:value={lineName} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="url">{m["productForm_url"]()}</Label>
|
||||
<Input id="url" name="url" type="url" placeholder={m["productForm_urlPlaceholder"]()} bind:value={url} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="sku">{m["productForm_sku"]()}</Label>
|
||||
<Input id="sku" name="sku" placeholder={m["productForm_skuPlaceholder"]()} bind:value={sku} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="barcode">{m["productForm_barcode"]()}</Label>
|
||||
<Input id="barcode" name="barcode" placeholder={m["productForm_barcodePlaceholder"]()} bind:value={barcode} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{#await import('$lib/components/product-form/ProductFormBasicSection.svelte') then mod}
|
||||
{@const BasicSection = mod.default}
|
||||
<BasicSection
|
||||
visible={editSection === 'basic'}
|
||||
bind:name
|
||||
bind:brand
|
||||
bind:lineName
|
||||
bind:url
|
||||
bind:sku
|
||||
bind:barcode
|
||||
/>
|
||||
{/await}
|
||||
|
||||
<!-- ── Classification ────────────────────────────────────────────────────── -->
|
||||
<Card class={editSection === 'basic' ? '' : 'hidden'}>
|
||||
<CardHeader><CardTitle>{m["productForm_classification"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="col-span-2 space-y-2">
|
||||
<Label>{m["productForm_category"]()}</Label>
|
||||
<input type="hidden" name="category" value={category} />
|
||||
<Select type="single" value={category} onValueChange={(v) => (category = v)}>
|
||||
<SelectTrigger>{category ? categoryLabels[category] : m["productForm_selectCategory"]()}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each categories as cat}
|
||||
<SelectItem value={cat}>{categoryLabels[cat]}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{m["productForm_time"]()}</Label>
|
||||
<input type="hidden" name="recommended_time" value={recommendedTime} />
|
||||
<Select type="single" value={recommendedTime} onValueChange={(v) => (recommendedTime = v)}>
|
||||
<SelectTrigger>
|
||||
{recommendedTime ? recommendedTime.toUpperCase() : m["productForm_timeOptions"]()}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="am">{m.common_am()}</SelectItem>
|
||||
<SelectItem value="pm">{m.common_pm()}</SelectItem>
|
||||
<SelectItem value="both">{m["productForm_timeBoth"]()}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{m["productForm_leaveOn"]()}</Label>
|
||||
<input type="hidden" name="leave_on" value={leaveOn} />
|
||||
<Select type="single" value={leaveOn} onValueChange={(v) => (leaveOn = v)}>
|
||||
<SelectTrigger>{leaveOn === 'true' ? m["productForm_leaveOnYes"]() : m["productForm_leaveOnNo"]()}</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">{m["productForm_leaveOnYes"]()}</SelectItem>
|
||||
<SelectItem value="false">{m["productForm_leaveOnNo"]()}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{m["productForm_texture"]()}</Label>
|
||||
<input type="hidden" name="texture" value={texture} />
|
||||
<Select type="single" value={texture} onValueChange={(v) => (texture = v)}>
|
||||
<SelectTrigger>{texture ? textureLabels[texture] : m["productForm_selectTexture"]()}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each textures as t}
|
||||
<SelectItem value={t}>{textureLabels[t]}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{m["productForm_absorptionSpeed"]()}</Label>
|
||||
<input type="hidden" name="absorption_speed" value={absorptionSpeed} />
|
||||
<Select type="single" value={absorptionSpeed} onValueChange={(v) => (absorptionSpeed = v)}>
|
||||
<SelectTrigger>{absorptionSpeed ? absorptionLabels[absorptionSpeed] : m["productForm_selectSpeed"]()}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each absorptionSpeeds as s}
|
||||
<SelectItem value={s}>{absorptionLabels[s]}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{#await import('$lib/components/product-form/ProductFormClassificationSection.svelte') then mod}
|
||||
{@const ClassificationSection = mod.default}
|
||||
<ClassificationSection
|
||||
visible={editSection === 'basic'}
|
||||
{selectClass}
|
||||
{categories}
|
||||
{textures}
|
||||
{absorptionSpeeds}
|
||||
{categoryLabels}
|
||||
{textureLabels}
|
||||
{absorptionLabels}
|
||||
bind:category
|
||||
bind:recommendedTime
|
||||
bind:leaveOn
|
||||
bind:texture
|
||||
bind:absorptionSpeed
|
||||
/>
|
||||
{/await}
|
||||
|
||||
<!-- ── Skin profile ───────────────────────────────────────────────────────── -->
|
||||
<Card class={editSection === 'ingredients' ? '' : 'hidden'}>
|
||||
|
|
@ -651,7 +544,7 @@
|
|||
<div class="space-y-2">
|
||||
<Label>{m["productForm_recommendedFor"]()}</Label>
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{#each skinTypes as st}
|
||||
{#each skinTypes as st (st)}
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -675,7 +568,7 @@
|
|||
<div class="space-y-2">
|
||||
<Label>{m["productForm_targetConcerns"]()}</Label>
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{#each skinConcerns as sc}
|
||||
{#each skinConcerns as sc (sc)}
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -734,7 +627,7 @@
|
|||
onclick={() => (activesPanelOpen = !activesPanelOpen)}
|
||||
>
|
||||
<span class="text-sm font-medium">{m["productForm_activeIngredients"]()}</span>
|
||||
<span class="text-xs text-muted-foreground">{activesPanelOpen ? 'Ukryj' : 'Pokaż'}</span>
|
||||
<span class="text-xs text-muted-foreground">{activesPanelOpen ? '−' : '+'}</span>
|
||||
</button>
|
||||
<Button type="button" variant="outline" size="sm" onclick={addActive}>{m["productForm_addActive"]()}</Button>
|
||||
</div>
|
||||
|
|
@ -742,7 +635,7 @@
|
|||
<input type="hidden" name="actives_json" value={activesJson} />
|
||||
|
||||
{#if activesPanelOpen}
|
||||
{#each actives as active, i}
|
||||
{#each actives as active, i (i)}
|
||||
<div class="rounded-md border border-border p-3 space-y-3">
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
|
|
@ -795,7 +688,7 @@
|
|||
<div class="space-y-1">
|
||||
<Label class="text-xs text-muted-foreground">{m["productForm_activeFunctions"]()}</Label>
|
||||
<div class="grid grid-cols-2 gap-1 sm:grid-cols-4">
|
||||
{#each ingFunctions as fn}
|
||||
{#each ingFunctions as fn (fn)}
|
||||
<label class="flex cursor-pointer items-center gap-1.5 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -820,319 +713,59 @@
|
|||
</Card>
|
||||
|
||||
<!-- ── Effect profile ─────────────────────────────────────────────────────── -->
|
||||
<Card class={editSection === 'assessment' ? '' : 'hidden'}>
|
||||
<CardHeader><CardTitle>{m["productForm_effectProfile"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{#each effectFields as field}
|
||||
{@const key = field.key as keyof typeof effectValues}
|
||||
<div class="grid grid-cols-[minmax(7rem,10rem)_1fr_1.25rem] items-center gap-3">
|
||||
<span class="text-xs text-muted-foreground">{field.label}</span>
|
||||
<input
|
||||
type="range"
|
||||
name="effect_{field.key}"
|
||||
min="0"
|
||||
max="5"
|
||||
step="1"
|
||||
bind:value={effectValues[key]}
|
||||
class="accent-primary"
|
||||
/>
|
||||
<span class="text-center font-mono text-sm">{effectValues[key]}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{#await import('$lib/components/product-form/ProductFormAssessmentSection.svelte') then mod}
|
||||
{@const AssessmentSection = mod.default}
|
||||
<AssessmentSection
|
||||
visible={editSection === 'assessment'}
|
||||
{selectClass}
|
||||
{effectFields}
|
||||
bind:effectValues
|
||||
{tristate}
|
||||
bind:ctxAfterShaving
|
||||
bind:ctxAfterAcids
|
||||
bind:ctxAfterRetinoids
|
||||
bind:ctxCompromisedBarrier
|
||||
bind:ctxLowUvOnly
|
||||
bind:fragranceFree
|
||||
bind:essentialOilsFree
|
||||
bind:alcoholDenatFree
|
||||
bind:pregnancySafe
|
||||
/>
|
||||
{/await}
|
||||
|
||||
<!-- ── Context rules ──────────────────────────────────────────────────────── -->
|
||||
<Card class={editSection === 'assessment' ? '' : 'hidden'}>
|
||||
<CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label>{m["productForm_ctxAfterShaving"]()}</Label>
|
||||
<input type="hidden" name="ctx_safe_after_shaving" value={ctxAfterShaving} />
|
||||
<Select type="single" value={ctxAfterShaving} onValueChange={(v) => (ctxAfterShaving = v)}>
|
||||
<SelectTrigger>{tristateLabel(ctxAfterShaving)}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{#await import('$lib/components/product-form/ProductFormDetailsSection.svelte') then mod}
|
||||
{@const DetailsSection = mod.default}
|
||||
<DetailsSection
|
||||
visible={editSection === 'details'}
|
||||
{textareaClass}
|
||||
bind:priceAmount
|
||||
bind:priceCurrency
|
||||
bind:sizeMl
|
||||
bind:fullWeightG
|
||||
bind:emptyWeightG
|
||||
bind:paoMonths
|
||||
bind:phMin
|
||||
bind:phMax
|
||||
bind:usageNotes
|
||||
bind:minIntervalHours
|
||||
bind:maxFrequencyPerWeek
|
||||
bind:needleLengthMm
|
||||
bind:isMedication
|
||||
bind:isTool
|
||||
{computedPriceLabel}
|
||||
{computedPricePerUseLabel}
|
||||
{computedPriceTierLabel}
|
||||
/>
|
||||
{/await}
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{m["productForm_ctxAfterAcids"]()}</Label>
|
||||
<input type="hidden" name="ctx_safe_after_acids" value={ctxAfterAcids} />
|
||||
<Select type="single" value={ctxAfterAcids} onValueChange={(v) => (ctxAfterAcids = v)}>
|
||||
<SelectTrigger>{tristateLabel(ctxAfterAcids)}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{m["productForm_ctxAfterRetinoids"]()}</Label>
|
||||
<input type="hidden" name="ctx_safe_after_retinoids" value={ctxAfterRetinoids} />
|
||||
<Select
|
||||
type="single"
|
||||
value={ctxAfterRetinoids}
|
||||
onValueChange={(v) => (ctxAfterRetinoids = v)}
|
||||
>
|
||||
<SelectTrigger>{tristateLabel(ctxAfterRetinoids)}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{m["productForm_ctxCompromisedBarrier"]()}</Label>
|
||||
<input type="hidden" name="ctx_safe_with_compromised_barrier" value={ctxCompromisedBarrier} />
|
||||
<Select
|
||||
type="single"
|
||||
value={ctxCompromisedBarrier}
|
||||
onValueChange={(v) => (ctxCompromisedBarrier = v)}
|
||||
>
|
||||
<SelectTrigger>{tristateLabel(ctxCompromisedBarrier)}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{m["productForm_ctxLowUvOnly"]()}</Label>
|
||||
<input type="hidden" name="ctx_low_uv_only" value={ctxLowUvOnly} />
|
||||
<Select type="single" value={ctxLowUvOnly} onValueChange={(v) => (ctxLowUvOnly = v)}>
|
||||
<SelectTrigger>{tristateLabel(ctxLowUvOnly)}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- ── Product details ────────────────────────────────────────────────────── -->
|
||||
<Card class={editSection === 'details' ? '' : 'hidden'}>
|
||||
<CardHeader><CardTitle>{m["productForm_productDetails"]()}</CardTitle></CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_16rem]">
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
<Label for="price_amount">{m["productForm_price"]()}</Label>
|
||||
<Input id="price_amount" name="price_amount" type="number" min="0" step="0.01" placeholder={m["productForm_priceAmountPlaceholder"]()} bind:value={priceAmount} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="price_currency">{m["productForm_currency"]()}</Label>
|
||||
<Input id="price_currency" name="price_currency" maxlength={3} placeholder={m["productForm_priceCurrencyPlaceholder"]()} bind:value={priceCurrency} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="size_ml">{m["productForm_sizeMl"]()}</Label>
|
||||
<Input id="size_ml" name="size_ml" type="number" min="0" step="0.1" placeholder={m["productForm_sizePlaceholder"]()} bind:value={sizeMl} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="full_weight_g">{m["productForm_fullWeightG"]()}</Label>
|
||||
<Input id="full_weight_g" name="full_weight_g" type="number" min="0" step="0.1" placeholder={m["productForm_fullWeightPlaceholder"]()} bind:value={fullWeightG} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="empty_weight_g">{m["productForm_emptyWeightG"]()}</Label>
|
||||
<Input id="empty_weight_g" name="empty_weight_g" type="number" min="0" step="0.1" placeholder={m["productForm_emptyWeightPlaceholder"]()} bind:value={emptyWeightG} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="pao_months">{m["productForm_paoMonths"]()}</Label>
|
||||
<Input id="pao_months" name="pao_months" type="number" min="1" max="60" placeholder={m["productForm_paoPlaceholder"]()} bind:value={paoMonths} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if computedPriceLabel || computedPricePerUseLabel || computedPriceTierLabel}
|
||||
<div class="rounded-md border border-border bg-muted/25 p-3 text-sm">
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<p class="text-muted-foreground">{m["productForm_price"]()}</p>
|
||||
<p class="font-medium">{computedPriceLabel ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">{m.common_pricePerUse()}</p>
|
||||
<p class="font-medium">{computedPricePerUseLabel ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">{m["productForm_priceTier"]()}</p>
|
||||
<p class="font-medium">{computedPriceTierLabel ?? 'n/a'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="ph_min">{m["productForm_phMin"]()}</Label>
|
||||
<Input id="ph_min" name="ph_min" type="number" min="0" max="14" step="0.1" placeholder={m["productForm_phMinPlaceholder"]()} bind:value={phMin} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="ph_max">{m["productForm_phMax"]()}</Label>
|
||||
<Input id="ph_max" name="ph_max" type="number" min="0" max="14" step="0.1" placeholder={m["productForm_phMaxPlaceholder"]()} bind:value={phMax} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="usage_notes">{m["productForm_usageNotes"]()}</Label>
|
||||
<textarea
|
||||
id="usage_notes"
|
||||
name="usage_notes"
|
||||
rows="2"
|
||||
placeholder={m["productForm_usageNotesPlaceholder"]()}
|
||||
class={textareaClass}
|
||||
bind:value={usageNotes}
|
||||
></textarea>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- ── Safety flags ───────────────────────────────────────────────────────── -->
|
||||
<Card class={editSection === 'assessment' ? '' : 'hidden'}>
|
||||
<CardHeader><CardTitle>{m["productForm_safetyFlags"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label>{m["productForm_fragranceFree"]()}</Label>
|
||||
<input type="hidden" name="fragrance_free" value={fragranceFree} />
|
||||
<Select type="single" value={fragranceFree} onValueChange={(v) => (fragranceFree = v)}>
|
||||
<SelectTrigger>{tristateLabel(fragranceFree)}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{m["productForm_essentialOilsFree"]()}</Label>
|
||||
<input type="hidden" name="essential_oils_free" value={essentialOilsFree} />
|
||||
<Select
|
||||
type="single"
|
||||
value={essentialOilsFree}
|
||||
onValueChange={(v) => (essentialOilsFree = v)}
|
||||
>
|
||||
<SelectTrigger>{tristateLabel(essentialOilsFree)}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{m["productForm_alcoholDenatFree"]()}</Label>
|
||||
<input type="hidden" name="alcohol_denat_free" value={alcoholDenatFree} />
|
||||
<Select
|
||||
type="single"
|
||||
value={alcoholDenatFree}
|
||||
onValueChange={(v) => (alcoholDenatFree = v)}
|
||||
>
|
||||
<SelectTrigger>{tristateLabel(alcoholDenatFree)}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{m["productForm_pregnancySafe"]()}</Label>
|
||||
<input type="hidden" name="pregnancy_safe" value={pregnancySafe} />
|
||||
<Select type="single" value={pregnancySafe} onValueChange={(v) => (pregnancySafe = v)}>
|
||||
<SelectTrigger>{tristateLabel(pregnancySafe)}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each tristate as opt}<SelectItem value={opt.value}>{opt.label}</SelectItem>{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- ── Usage constraints ──────────────────────────────────────────────────── -->
|
||||
<Card class={editSection === 'details' ? '' : 'hidden'}>
|
||||
<CardHeader><CardTitle>{m["productForm_usageConstraints"]()}</CardTitle></CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="min_interval_hours">{m["productForm_minIntervalHours"]()}</Label>
|
||||
<Input id="min_interval_hours" name="min_interval_hours" type="number" min="0" placeholder={m["productForm_minIntervalPlaceholder"]()} bind:value={minIntervalHours} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="max_frequency_per_week">{m["productForm_maxFrequencyPerWeek"]()}</Label>
|
||||
<Input id="max_frequency_per_week" name="max_frequency_per_week" type="number" min="1" max="14" placeholder={m["productForm_maxFrequencyPlaceholder"]()} bind:value={maxFrequencyPerWeek} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6">
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input type="hidden" name="is_medication" value={String(isMedication)} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isMedication}
|
||||
onchange={() => (isMedication = !isMedication)}
|
||||
class="rounded border-input"
|
||||
/>
|
||||
{m["productForm_isMedication"]()}
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input type="hidden" name="is_tool" value={String(isTool)} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isTool}
|
||||
onchange={() => (isTool = !isTool)}
|
||||
class="rounded border-input"
|
||||
/>
|
||||
{m["productForm_isTool"]()}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="needle_length_mm">{m["productForm_needleLengthMm"]()}</Label>
|
||||
<Input id="needle_length_mm" name="needle_length_mm" type="number" min="0" step="0.01" placeholder={m["productForm_needleLengthPlaceholder"]()} bind:value={needleLengthMm} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- ── Personal notes ─────────────────────────────────────────────────────── -->
|
||||
<Card class={editSection === 'notes' ? '' : 'hidden'}>
|
||||
<CardHeader><CardTitle>{m["productForm_personalNotes"]()}</CardTitle></CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>{m["productForm_repurchaseIntent"]()}</Label>
|
||||
<input type="hidden" name="personal_repurchase_intent" value={personalRepurchaseIntent} />
|
||||
<Select
|
||||
type="single"
|
||||
value={personalRepurchaseIntent}
|
||||
onValueChange={(v) => (personalRepurchaseIntent = v)}
|
||||
>
|
||||
<SelectTrigger>{tristateLabel(personalRepurchaseIntent)}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each tristate as opt}
|
||||
<SelectItem value={opt.value}>{opt.label}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="personal_tolerance_notes">{m["productForm_toleranceNotes"]()}</Label>
|
||||
<textarea
|
||||
id="personal_tolerance_notes"
|
||||
name="personal_tolerance_notes"
|
||||
rows="2"
|
||||
placeholder={m["productForm_toleranceNotesPlaceholder"]()}
|
||||
class={textareaClass}
|
||||
bind:value={personalToleranceNotes}
|
||||
></textarea>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{#await import('$lib/components/product-form/ProductFormNotesSection.svelte') then mod}
|
||||
{@const NotesSection = mod.default}
|
||||
<NotesSection
|
||||
visible={editSection === 'notes'}
|
||||
{selectClass}
|
||||
{textareaClass}
|
||||
{tristate}
|
||||
bind:personalRepurchaseIntent
|
||||
bind:personalToleranceNotes
|
||||
/>
|
||||
{/await}
|
||||
|
|
|
|||
62
frontend/src/lib/components/ProductFormAiModal.svelte
Normal file
62
frontend/src/lib/components/ProductFormAiModal.svelte
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Sparkles, X } from 'lucide-svelte';
|
||||
|
||||
let {
|
||||
open = false,
|
||||
aiText = $bindable(''),
|
||||
aiLoading = false,
|
||||
aiError = '',
|
||||
textareaClass,
|
||||
onClose,
|
||||
onSubmit
|
||||
}: {
|
||||
open?: boolean;
|
||||
aiText?: string;
|
||||
aiLoading?: boolean;
|
||||
aiError?: string;
|
||||
textareaClass: string;
|
||||
onClose: () => void;
|
||||
onSubmit: () => void;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<button
|
||||
type="button"
|
||||
class="fixed inset-0 z-50 bg-black/50"
|
||||
onclick={onClose}
|
||||
aria-label={m.common_cancel()}
|
||||
></button>
|
||||
<div class="fixed inset-x-3 bottom-3 top-3 z-50 mx-auto flex max-w-2xl items-center md:inset-x-6 md:inset-y-8">
|
||||
<Card class="max-h-full w-full overflow-hidden">
|
||||
<CardHeader class="border-b border-border">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<CardTitle>{m["productForm_aiPrefill"]()}</CardTitle>
|
||||
<Button type="button" variant="ghost" size="sm" class="h-8 w-8 p-0" onclick={onClose} aria-label={m.common_cancel()}>
|
||||
<X class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-3 overflow-y-auto p-4">
|
||||
<p class="text-sm text-muted-foreground">{m["productForm_aiPrefillText"]()}</p>
|
||||
<textarea bind:value={aiText} rows="8" placeholder={m["productForm_pasteText"]()} class={textareaClass}></textarea>
|
||||
{#if aiError}
|
||||
<p class="text-sm text-destructive">{aiError}</p>
|
||||
{/if}
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onclick={onClose} disabled={aiLoading}>{m.common_cancel()}</Button>
|
||||
<Button type="button" onclick={onSubmit} disabled={aiLoading || !aiText.trim()}>
|
||||
{#if aiLoading}
|
||||
{m["productForm_parsing"]()}
|
||||
{:else}
|
||||
<Sparkles class="size-4" /> {m["productForm_parseWithAI"]()}
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
24
frontend/src/lib/components/forms/FormSectionCard.svelte
Normal file
24
frontend/src/lib/components/forms/FormSectionCard.svelte
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<script lang="ts">
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
|
||||
let {
|
||||
title,
|
||||
titleClass = 'text-base',
|
||||
className,
|
||||
contentClassName,
|
||||
children
|
||||
}: {
|
||||
title: string;
|
||||
titleClass?: string;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
children?: import('svelte').Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Card class={className}>
|
||||
<CardHeader><CardTitle class={titleClass}>{title}</CardTitle></CardHeader>
|
||||
<CardContent class={contentClassName}>
|
||||
{@render children?.()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
54
frontend/src/lib/components/forms/GroupedSelect.svelte
Normal file
54
frontend/src/lib/components/forms/GroupedSelect.svelte
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<script lang="ts">
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { baseSelectClass } from '$lib/components/forms/form-classes';
|
||||
|
||||
type SelectOption = { value: string; label: string };
|
||||
type SelectGroup = { label: string; options: SelectOption[] };
|
||||
|
||||
let {
|
||||
id,
|
||||
name,
|
||||
label,
|
||||
groups,
|
||||
value = $bindable(''),
|
||||
placeholder = '',
|
||||
required = false,
|
||||
className = '',
|
||||
onChange
|
||||
}: {
|
||||
id: string;
|
||||
name?: string;
|
||||
label: string;
|
||||
groups: SelectGroup[];
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
onChange?: (value: string) => void;
|
||||
} = $props();
|
||||
|
||||
const selectClass = $derived(className ? `${baseSelectClass} ${className}` : baseSelectClass);
|
||||
</script>
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label for={id}>{label}</Label>
|
||||
<select
|
||||
{id}
|
||||
{name}
|
||||
class={selectClass}
|
||||
bind:value
|
||||
{required}
|
||||
onchange={(e) => onChange?.(e.currentTarget.value)}
|
||||
>
|
||||
{#if placeholder}
|
||||
<option value="">{placeholder}</option>
|
||||
{/if}
|
||||
{#each groups as group (group.label)}
|
||||
<optgroup label={group.label}>
|
||||
{#each group.options as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</optgroup>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
44
frontend/src/lib/components/forms/HintCheckbox.svelte
Normal file
44
frontend/src/lib/components/forms/HintCheckbox.svelte
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<script lang="ts">
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
let {
|
||||
id,
|
||||
name,
|
||||
label,
|
||||
hint,
|
||||
checked = $bindable(false),
|
||||
disabled = false,
|
||||
className = ''
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
checked?: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
} = $props();
|
||||
|
||||
const wrapperClass = $derived(
|
||||
className
|
||||
? `flex items-start gap-3 rounded-md border border-border px-3 py-2 ${className}`
|
||||
: 'flex items-start gap-3 rounded-md border border-border px-3 py-2'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class={wrapperClass}>
|
||||
<input
|
||||
{id}
|
||||
{name}
|
||||
type="checkbox"
|
||||
class="mt-0.5 h-4 w-4 rounded border-input"
|
||||
bind:checked
|
||||
{disabled}
|
||||
/>
|
||||
<div class="space-y-0.5">
|
||||
<Label for={id} class="font-medium">{label}</Label>
|
||||
{#if hint}
|
||||
<p class="text-xs text-muted-foreground">{hint}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
45
frontend/src/lib/components/forms/LabeledInputField.svelte
Normal file
45
frontend/src/lib/components/forms/LabeledInputField.svelte
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<script lang="ts">
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
let {
|
||||
id,
|
||||
name,
|
||||
label,
|
||||
value = $bindable(''),
|
||||
type = 'text',
|
||||
placeholder,
|
||||
required = false,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
className = 'space-y-1'
|
||||
}: {
|
||||
id: string;
|
||||
name: string;
|
||||
label: string;
|
||||
value?: string;
|
||||
type?: 'text' | 'number' | 'date' | 'url';
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
min?: string;
|
||||
max?: string;
|
||||
step?: string;
|
||||
className?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class={className}>
|
||||
<Label for={id}>{label}</Label>
|
||||
<Input
|
||||
{id}
|
||||
{name}
|
||||
{type}
|
||||
{required}
|
||||
{placeholder}
|
||||
{min}
|
||||
{max}
|
||||
{step}
|
||||
bind:value
|
||||
/>
|
||||
</div>
|
||||
49
frontend/src/lib/components/forms/SimpleSelect.svelte
Normal file
49
frontend/src/lib/components/forms/SimpleSelect.svelte
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<script lang="ts">
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { baseSelectClass } from '$lib/components/forms/form-classes';
|
||||
|
||||
type SelectOption = { value: string; label: string };
|
||||
|
||||
let {
|
||||
id,
|
||||
name,
|
||||
label,
|
||||
options,
|
||||
value = $bindable(''),
|
||||
placeholder = '',
|
||||
required = false,
|
||||
className = '',
|
||||
onChange
|
||||
}: {
|
||||
id: string;
|
||||
name?: string;
|
||||
label: string;
|
||||
options: SelectOption[];
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
onChange?: (value: string) => void;
|
||||
} = $props();
|
||||
|
||||
const selectClass = $derived(className ? `${baseSelectClass} ${className}` : baseSelectClass);
|
||||
</script>
|
||||
|
||||
<div class="space-y-1">
|
||||
<Label for={id}>{label}</Label>
|
||||
<select
|
||||
{id}
|
||||
{name}
|
||||
class={selectClass}
|
||||
bind:value
|
||||
{required}
|
||||
onchange={(e) => onChange?.(e.currentTarget.value)}
|
||||
>
|
||||
{#if placeholder}
|
||||
<option value="">{placeholder}</option>
|
||||
{/if}
|
||||
{#each options as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
5
frontend/src/lib/components/forms/form-classes.ts
Normal file
5
frontend/src/lib/components/forms/form-classes.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export const baseSelectClass =
|
||||
'h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring';
|
||||
|
||||
export const baseTextareaClass =
|
||||
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
type EffectField = { key: string; label: string };
|
||||
type TriOption = { value: string; label: string };
|
||||
|
||||
let {
|
||||
visible = false,
|
||||
selectClass,
|
||||
effectFields,
|
||||
effectValues = $bindable<Record<string, number>>({}),
|
||||
tristate,
|
||||
ctxAfterShaving = $bindable(''),
|
||||
ctxAfterAcids = $bindable(''),
|
||||
ctxAfterRetinoids = $bindable(''),
|
||||
ctxCompromisedBarrier = $bindable(''),
|
||||
ctxLowUvOnly = $bindable(''),
|
||||
fragranceFree = $bindable(''),
|
||||
essentialOilsFree = $bindable(''),
|
||||
alcoholDenatFree = $bindable(''),
|
||||
pregnancySafe = $bindable('')
|
||||
}: {
|
||||
visible?: boolean;
|
||||
selectClass: string;
|
||||
effectFields: EffectField[];
|
||||
effectValues?: Record<string, number>;
|
||||
tristate: TriOption[];
|
||||
ctxAfterShaving?: string;
|
||||
ctxAfterAcids?: string;
|
||||
ctxAfterRetinoids?: string;
|
||||
ctxCompromisedBarrier?: string;
|
||||
ctxLowUvOnly?: string;
|
||||
fragranceFree?: string;
|
||||
essentialOilsFree?: string;
|
||||
alcoholDenatFree?: string;
|
||||
pregnancySafe?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Card class={visible ? '' : 'hidden'}>
|
||||
<CardHeader><CardTitle>{m["productForm_effectProfile"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{#each effectFields as field (field.key)}
|
||||
{@const key = field.key}
|
||||
<div class="grid grid-cols-[minmax(7rem,10rem)_1fr_1.25rem] items-center gap-3">
|
||||
<span class="text-xs text-muted-foreground">{field.label}</span>
|
||||
<input
|
||||
type="range"
|
||||
name="effect_{field.key}"
|
||||
min="0"
|
||||
max="5"
|
||||
step="1"
|
||||
bind:value={effectValues[key]}
|
||||
class="accent-primary"
|
||||
/>
|
||||
<span class="text-center font-mono text-sm">{effectValues[key]}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class={visible ? '' : 'hidden'}>
|
||||
<CardHeader><CardTitle>{m["productForm_contextRules"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="ctx_shaving_select">{m["productForm_ctxAfterShaving"]()}</Label>
|
||||
<select id="ctx_shaving_select" name="ctx_safe_after_shaving" class={selectClass} bind:value={ctxAfterShaving}>
|
||||
{#each tristate as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="ctx_acids_select">{m["productForm_ctxAfterAcids"]()}</Label>
|
||||
<select id="ctx_acids_select" name="ctx_safe_after_acids" class={selectClass} bind:value={ctxAfterAcids}>
|
||||
{#each tristate as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="ctx_retinoids_select">{m["productForm_ctxAfterRetinoids"]()}</Label>
|
||||
<select id="ctx_retinoids_select" name="ctx_safe_after_retinoids" class={selectClass} bind:value={ctxAfterRetinoids}>
|
||||
{#each tristate as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="ctx_barrier_select">{m["productForm_ctxCompromisedBarrier"]()}</Label>
|
||||
<select id="ctx_barrier_select" name="ctx_safe_with_compromised_barrier" class={selectClass} bind:value={ctxCompromisedBarrier}>
|
||||
{#each tristate as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="ctx_uv_select">{m["productForm_ctxLowUvOnly"]()}</Label>
|
||||
<select id="ctx_uv_select" name="ctx_low_uv_only" class={selectClass} bind:value={ctxLowUvOnly}>
|
||||
{#each tristate as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class={visible ? '' : 'hidden'}>
|
||||
<CardHeader><CardTitle>{m["productForm_safetyFlags"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="fragrance_free_select">{m["productForm_fragranceFree"]()}</Label>
|
||||
<select id="fragrance_free_select" name="fragrance_free" class={selectClass} bind:value={fragranceFree}>
|
||||
{#each tristate as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="essential_oils_free_select">{m["productForm_essentialOilsFree"]()}</Label>
|
||||
<select id="essential_oils_free_select" name="essential_oils_free" class={selectClass} bind:value={essentialOilsFree}>
|
||||
{#each tristate as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="alcohol_denat_free_select">{m["productForm_alcoholDenatFree"]()}</Label>
|
||||
<select id="alcohol_denat_free_select" name="alcohol_denat_free" class={selectClass} bind:value={alcoholDenatFree}>
|
||||
{#each tristate as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="pregnancy_safe_select">{m["productForm_pregnancySafe"]()}</Label>
|
||||
<select id="pregnancy_safe_select" name="pregnancy_safe" class={selectClass} bind:value={pregnancySafe}>
|
||||
{#each tristate as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
let {
|
||||
visible = true,
|
||||
name = $bindable(''),
|
||||
brand = $bindable(''),
|
||||
lineName = $bindable(''),
|
||||
url = $bindable(''),
|
||||
sku = $bindable(''),
|
||||
barcode = $bindable('')
|
||||
}: {
|
||||
visible?: boolean;
|
||||
name?: string;
|
||||
brand?: string;
|
||||
lineName?: string;
|
||||
url?: string;
|
||||
sku?: string;
|
||||
barcode?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Card class={visible ? '' : 'hidden'}>
|
||||
<CardHeader><CardTitle>{m["productForm_basicInfo"]()}</CardTitle></CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="name">{m["productForm_name"]()}</Label>
|
||||
<Input id="name" name="name" required placeholder={m["productForm_namePlaceholder"]()} bind:value={name} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="brand">{m["productForm_brand"]()}</Label>
|
||||
<Input id="brand" name="brand" required placeholder={m["productForm_brandPlaceholder"]()} bind:value={brand} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="line_name">{m["productForm_lineName"]()}</Label>
|
||||
<Input id="line_name" name="line_name" placeholder={m["productForm_lineNamePlaceholder"]()} bind:value={lineName} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="url">{m["productForm_url"]()}</Label>
|
||||
<Input id="url" name="url" type="url" placeholder={m["productForm_urlPlaceholder"]()} bind:value={url} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="sku">{m["productForm_sku"]()}</Label>
|
||||
<Input id="sku" name="sku" placeholder={m["productForm_skuPlaceholder"]()} bind:value={sku} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="barcode">{m["productForm_barcode"]()}</Label>
|
||||
<Input id="barcode" name="barcode" placeholder={m["productForm_barcodePlaceholder"]()} bind:value={barcode} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
let {
|
||||
visible = true,
|
||||
selectClass,
|
||||
categories,
|
||||
textures,
|
||||
absorptionSpeeds,
|
||||
categoryLabels,
|
||||
textureLabels,
|
||||
absorptionLabels,
|
||||
category = $bindable(''),
|
||||
recommendedTime = $bindable(''),
|
||||
leaveOn = $bindable('true'),
|
||||
texture = $bindable(''),
|
||||
absorptionSpeed = $bindable('')
|
||||
}: {
|
||||
visible?: boolean;
|
||||
selectClass: string;
|
||||
categories: string[];
|
||||
textures: string[];
|
||||
absorptionSpeeds: string[];
|
||||
categoryLabels: Record<string, string>;
|
||||
textureLabels: Record<string, string>;
|
||||
absorptionLabels: Record<string, string>;
|
||||
category?: string;
|
||||
recommendedTime?: string;
|
||||
leaveOn?: string;
|
||||
texture?: string;
|
||||
absorptionSpeed?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Card class={visible ? '' : 'hidden'}>
|
||||
<CardHeader><CardTitle>{m["productForm_classification"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="col-span-2 space-y-2">
|
||||
<Label for="category_select">{m["productForm_category"]()}</Label>
|
||||
<select id="category_select" name="category" class={selectClass} bind:value={category}>
|
||||
<option value="">{m["productForm_selectCategory"]()}</option>
|
||||
{#each categories as cat (cat)}
|
||||
<option value={cat}>{categoryLabels[cat]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="recommended_time_select">{m["productForm_time"]()}</Label>
|
||||
<select id="recommended_time_select" name="recommended_time" class={selectClass} bind:value={recommendedTime}>
|
||||
<option value="">{m["productForm_timeOptions"]()}</option>
|
||||
<option value="am">{m.common_am()}</option>
|
||||
<option value="pm">{m.common_pm()}</option>
|
||||
<option value="both">{m["productForm_timeBoth"]()}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="leave_on_select">{m["productForm_leaveOn"]()}</Label>
|
||||
<select id="leave_on_select" name="leave_on" class={selectClass} bind:value={leaveOn}>
|
||||
<option value="true">{m["productForm_leaveOnYes"]()}</option>
|
||||
<option value="false">{m["productForm_leaveOnNo"]()}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="texture_select">{m["productForm_texture"]()}</Label>
|
||||
<select id="texture_select" name="texture" class={selectClass} bind:value={texture}>
|
||||
<option value="">{m["productForm_selectTexture"]()}</option>
|
||||
{#each textures as t (t)}
|
||||
<option value={t}>{textureLabels[t]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="absorption_speed_select">{m["productForm_absorptionSpeed"]()}</Label>
|
||||
<select id="absorption_speed_select" name="absorption_speed" class={selectClass} bind:value={absorptionSpeed}>
|
||||
<option value="">{m["productForm_selectSpeed"]()}</option>
|
||||
{#each absorptionSpeeds as s (s)}
|
||||
<option value={s}>{absorptionLabels[s]}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
let {
|
||||
visible = false,
|
||||
textareaClass,
|
||||
priceAmount = $bindable(''),
|
||||
priceCurrency = $bindable('PLN'),
|
||||
sizeMl = $bindable(''),
|
||||
fullWeightG = $bindable(''),
|
||||
emptyWeightG = $bindable(''),
|
||||
paoMonths = $bindable(''),
|
||||
phMin = $bindable(''),
|
||||
phMax = $bindable(''),
|
||||
usageNotes = $bindable(''),
|
||||
minIntervalHours = $bindable(''),
|
||||
maxFrequencyPerWeek = $bindable(''),
|
||||
needleLengthMm = $bindable(''),
|
||||
isMedication = $bindable(false),
|
||||
isTool = $bindable(false),
|
||||
computedPriceLabel,
|
||||
computedPricePerUseLabel,
|
||||
computedPriceTierLabel
|
||||
}: {
|
||||
visible?: boolean;
|
||||
textareaClass: string;
|
||||
priceAmount?: string;
|
||||
priceCurrency?: string;
|
||||
sizeMl?: string;
|
||||
fullWeightG?: string;
|
||||
emptyWeightG?: string;
|
||||
paoMonths?: string;
|
||||
phMin?: string;
|
||||
phMax?: string;
|
||||
usageNotes?: string;
|
||||
minIntervalHours?: string;
|
||||
maxFrequencyPerWeek?: string;
|
||||
needleLengthMm?: string;
|
||||
isMedication?: boolean;
|
||||
isTool?: boolean;
|
||||
computedPriceLabel?: string;
|
||||
computedPricePerUseLabel?: string;
|
||||
computedPriceTierLabel?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Card class={visible ? '' : 'hidden'}>
|
||||
<CardHeader><CardTitle>{m["productForm_productDetails"]()}</CardTitle></CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_16rem]">
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<div class="space-y-2">
|
||||
<Label for="price_amount">{m["productForm_price"]()}</Label>
|
||||
<Input id="price_amount" name="price_amount" type="number" min="0" step="0.01" placeholder={m["productForm_priceAmountPlaceholder"]()} bind:value={priceAmount} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="price_currency">{m["productForm_currency"]()}</Label>
|
||||
<Input id="price_currency" name="price_currency" maxlength={3} placeholder={m["productForm_priceCurrencyPlaceholder"]()} bind:value={priceCurrency} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="size_ml">{m["productForm_sizeMl"]()}</Label>
|
||||
<Input id="size_ml" name="size_ml" type="number" min="0" step="0.1" placeholder={m["productForm_sizePlaceholder"]()} bind:value={sizeMl} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="full_weight_g">{m["productForm_fullWeightG"]()}</Label>
|
||||
<Input id="full_weight_g" name="full_weight_g" type="number" min="0" step="0.1" placeholder={m["productForm_fullWeightPlaceholder"]()} bind:value={fullWeightG} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="empty_weight_g">{m["productForm_emptyWeightG"]()}</Label>
|
||||
<Input id="empty_weight_g" name="empty_weight_g" type="number" min="0" step="0.1" placeholder={m["productForm_emptyWeightPlaceholder"]()} bind:value={emptyWeightG} />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="pao_months">{m["productForm_paoMonths"]()}</Label>
|
||||
<Input id="pao_months" name="pao_months" type="number" min="1" max="60" placeholder={m["productForm_paoPlaceholder"]()} bind:value={paoMonths} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if computedPriceLabel || computedPricePerUseLabel || computedPriceTierLabel}
|
||||
<div class="rounded-md border border-border bg-muted/25 p-3 text-sm">
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<p class="text-muted-foreground">{m["productForm_price"]()}</p>
|
||||
<p class="font-medium">{computedPriceLabel ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">{m.common_pricePerUse()}</p>
|
||||
<p class="font-medium">{computedPricePerUseLabel ?? '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted-foreground">{m["productForm_priceTier"]()}</p>
|
||||
<p class="font-medium">{computedPriceTierLabel ?? m.common_unknown()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="ph_min">{m["productForm_phMin"]()}</Label>
|
||||
<Input id="ph_min" name="ph_min" type="number" min="0" max="14" step="0.1" placeholder={m["productForm_phMinPlaceholder"]()} bind:value={phMin} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="ph_max">{m["productForm_phMax"]()}</Label>
|
||||
<Input id="ph_max" name="ph_max" type="number" min="0" max="14" step="0.1" placeholder={m["productForm_phMaxPlaceholder"]()} bind:value={phMax} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="usage_notes">{m["productForm_usageNotes"]()}</Label>
|
||||
<textarea
|
||||
id="usage_notes"
|
||||
name="usage_notes"
|
||||
rows="2"
|
||||
placeholder={m["productForm_usageNotesPlaceholder"]()}
|
||||
class={textareaClass}
|
||||
bind:value={usageNotes}
|
||||
></textarea>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class={visible ? '' : 'hidden'}>
|
||||
<CardHeader><CardTitle>{m["productForm_usageConstraints"]()}</CardTitle></CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="min_interval_hours">{m["productForm_minIntervalHours"]()}</Label>
|
||||
<Input id="min_interval_hours" name="min_interval_hours" type="number" min="0" placeholder={m["productForm_minIntervalPlaceholder"]()} bind:value={minIntervalHours} />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label for="max_frequency_per_week">{m["productForm_maxFrequencyPerWeek"]()}</Label>
|
||||
<Input id="max_frequency_per_week" name="max_frequency_per_week" type="number" min="1" max="14" placeholder={m["productForm_maxFrequencyPlaceholder"]()} bind:value={maxFrequencyPerWeek} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6">
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input type="hidden" name="is_medication" value={String(isMedication)} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isMedication}
|
||||
onchange={() => (isMedication = !isMedication)}
|
||||
class="rounded border-input"
|
||||
/>
|
||||
{m["productForm_isMedication"]()}
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm">
|
||||
<input type="hidden" name="is_tool" value={String(isTool)} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isTool}
|
||||
onchange={() => (isTool = !isTool)}
|
||||
class="rounded border-input"
|
||||
/>
|
||||
{m["productForm_isTool"]()}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="needle_length_mm">{m["productForm_needleLengthMm"]()}</Label>
|
||||
<Input id="needle_length_mm" name="needle_length_mm" type="number" min="0" step="0.01" placeholder={m["productForm_needleLengthPlaceholder"]()} bind:value={needleLengthMm} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<script lang="ts">
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
|
||||
type TriOption = { value: string; label: string };
|
||||
|
||||
let {
|
||||
visible = false,
|
||||
selectClass,
|
||||
textareaClass,
|
||||
tristate,
|
||||
personalRepurchaseIntent = $bindable(''),
|
||||
personalToleranceNotes = $bindable('')
|
||||
}: {
|
||||
visible?: boolean;
|
||||
selectClass: string;
|
||||
textareaClass: string;
|
||||
tristate: TriOption[];
|
||||
personalRepurchaseIntent?: string;
|
||||
personalToleranceNotes?: string;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<Card class={visible ? '' : 'hidden'}>
|
||||
<CardHeader><CardTitle>{m["productForm_personalNotes"]()}</CardTitle></CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="repurchase_intent_select">{m["productForm_repurchaseIntent"]()}</Label>
|
||||
<select id="repurchase_intent_select" name="personal_repurchase_intent" class={selectClass} bind:value={personalRepurchaseIntent}>
|
||||
{#each tristate as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="personal_tolerance_notes">{m["productForm_toleranceNotes"]()}</Label>
|
||||
<textarea
|
||||
id="personal_tolerance_notes"
|
||||
name="personal_tolerance_notes"
|
||||
rows="2"
|
||||
placeholder={m["productForm_toleranceNotesPlaceholder"]()}
|
||||
class={textareaClass}
|
||||
bind:value={personalToleranceNotes}
|
||||
></textarea>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -2,16 +2,16 @@
|
|||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const badgeVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border px-2 py-0.5 text-xs font-semibold whitespace-nowrap tracking-[0.08em] uppercase transition-[color,box-shadow,border-color,background-color] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3",
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent",
|
||||
"bg-[var(--page-accent)] text-white [a&]:hover:brightness-95 border-transparent",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent",
|
||||
destructive:
|
||||
"bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white",
|
||||
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
outline: "border-border bg-transparent text-foreground [a&]:hover:border-[color:var(--page-accent)] [a&]:hover:bg-[var(--page-accent-soft)]",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
|
|
|||
|
|
@ -4,17 +4,17 @@
|
|||
import { type VariantProps, tv } from "tailwind-variants";
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 rounded-md border text-sm font-semibold whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
|
||||
default: "border-transparent bg-[var(--page-accent)] text-white shadow-sm hover:brightness-95",
|
||||
destructive:
|
||||
"bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
|
||||
"border-transparent bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-white shadow-sm",
|
||||
outline:
|
||||
"bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border shadow-xs",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
"border-border bg-card text-card-foreground shadow-sm hover:border-[color:var(--page-accent)] hover:bg-[var(--page-accent-soft)]",
|
||||
secondary: "border-border bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "border-transparent text-muted-foreground hover:text-foreground hover:bg-[var(--page-accent-soft)]",
|
||||
link: "border-transparent px-0 text-[var(--page-accent)] underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
bind:this={ref}
|
||||
data-slot="card"
|
||||
class={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm shadow-[0_18px_36px_-32px_hsl(210_24%_15%_/_0.55)]",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
bind:this={ref}
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow,border-color] outline-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
bind:this={ref}
|
||||
data-slot={dataSlot}
|
||||
class={cn(
|
||||
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow,border-color] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
let { children } = $props();
|
||||
|
||||
let mobileMenuOpen = $state(false);
|
||||
const domainClass = $derived(getDomainClass(page.url.pathname));
|
||||
|
||||
const navItems = $derived([
|
||||
{ href: resolve('/'), label: m.nav_dashboard(), icon: House },
|
||||
|
|
@ -40,18 +41,27 @@
|
|||
);
|
||||
return !moreSpecific;
|
||||
}
|
||||
|
||||
function getDomainClass(pathname: string): string {
|
||||
if (pathname.startsWith('/products')) return 'domain-products';
|
||||
if (pathname.startsWith('/routines')) return 'domain-routines';
|
||||
if (pathname.startsWith('/skin')) return 'domain-skin';
|
||||
if (pathname.startsWith('/health/lab-results')) return 'domain-health-labs';
|
||||
if (pathname.startsWith('/health/medications')) return 'domain-health-meds';
|
||||
return 'domain-dashboard';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen flex-col bg-background md:flex-row">
|
||||
<div class="app-shell {domainClass}">
|
||||
<!-- Mobile header -->
|
||||
<header class="flex items-center justify-between border-b border-border bg-card px-4 py-3 md:hidden">
|
||||
<header class="app-mobile-header md:hidden">
|
||||
<div>
|
||||
<span class="text-sm font-semibold tracking-tight">{m["nav_appName"]()}</span>
|
||||
<span class="app-mobile-title">{m["nav_appName"]()}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||
class="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
class="app-icon-button"
|
||||
aria-label={m.common_toggleMenu()}
|
||||
>
|
||||
{#if mobileMenuOpen}
|
||||
|
|
@ -73,14 +83,14 @@
|
|||
></button>
|
||||
<!-- Drawer (same z-50 but later in DOM, on top) -->
|
||||
<nav
|
||||
class="fixed inset-y-0 left-0 z-50 w-64 overflow-y-auto bg-card px-3 py-6 md:hidden"
|
||||
class="fixed inset-y-0 left-0 z-50 w-64 overflow-y-auto bg-card px-3 py-6 md:hidden app-sidebar"
|
||||
>
|
||||
<div class="mb-8 px-3">
|
||||
<h1 class="text-lg font-semibold tracking-tight">{m["nav_appName"]()}</h1>
|
||||
<h1 class="app-brand">{m["nav_appName"]()}</h1>
|
||||
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p>
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
{#each navItems as item}
|
||||
{#each navItems as item (item.href)}
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
|
|
@ -103,13 +113,13 @@
|
|||
{/if}
|
||||
|
||||
<!-- Desktop Sidebar -->
|
||||
<nav class="hidden w-56 shrink-0 flex-col border-r border-border bg-card px-3 py-6 md:flex">
|
||||
<nav class="app-sidebar hidden w-56 shrink-0 flex-col px-3 py-6 md:flex">
|
||||
<div class="mb-8 px-3">
|
||||
<h1 class="text-lg font-semibold tracking-tight">{m["nav_appName"]()}</h1>
|
||||
<h1 class="app-brand">{m["nav_appName"]()}</h1>
|
||||
<p class="text-xs text-muted-foreground">{m["nav_appSubtitle"]()}</p>
|
||||
</div>
|
||||
<ul class="space-y-1">
|
||||
{#each navItems as item}
|
||||
{#each navItems as item (item.href)}
|
||||
<li>
|
||||
<a
|
||||
href={item.href}
|
||||
|
|
@ -130,7 +140,7 @@
|
|||
</nav>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="flex-1 overflow-auto p-4 md:p-8">
|
||||
<main class="app-main">
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,85 +1,137 @@
|
|||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import type { PageData } from './$types';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
const stateColors: Record<string, string> = {
|
||||
excellent: 'bg-green-100 text-green-800',
|
||||
good: 'bg-blue-100 text-blue-800',
|
||||
fair: 'bg-yellow-100 text-yellow-800',
|
||||
poor: 'bg-red-100 text-red-800'
|
||||
const stateTone: Record<string, string> = {
|
||||
excellent: 'state-pill state-pill--excellent',
|
||||
good: 'state-pill state-pill--good',
|
||||
fair: 'state-pill state-pill--fair',
|
||||
poor: 'state-pill state-pill--poor'
|
||||
};
|
||||
|
||||
const routineTone: Record<string, string> = {
|
||||
am: 'routine-pill routine-pill--am',
|
||||
pm: 'routine-pill routine-pill--pm'
|
||||
};
|
||||
|
||||
function humanize(text: string): string {
|
||||
return text
|
||||
.split('_')
|
||||
.map((chunk) => chunk.charAt(0).toUpperCase() + chunk.slice(1))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
const routineStats = $derived.by(() => {
|
||||
const amCount = data.recentRoutines.filter((routine) => routine.part_of_day === 'am').length;
|
||||
const pmCount = data.recentRoutines.length - amCount;
|
||||
return { amCount, pmCount };
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{m.dashboard_title()} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="space-y-8">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold tracking-tight">{m.dashboard_title()}</h2>
|
||||
<p class="text-muted-foreground">{m.dashboard_subtitle()}</p>
|
||||
</div>
|
||||
<div class="editorial-dashboard">
|
||||
<div class="editorial-atmosphere" aria-hidden="true"></div>
|
||||
|
||||
<div class="grid gap-6 md:grid-cols-2">
|
||||
<!-- Latest skin snapshot -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{m["dashboard_latestSnapshot"]()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if data.latestSnapshot}
|
||||
{@const s = data.latestSnapshot}
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-muted-foreground">{s.snapshot_date}</span>
|
||||
{#if s.overall_state}
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[s.overall_state] ?? ''}">
|
||||
{s.overall_state}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if s.active_concerns.length}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each s.active_concerns as concern (concern)}
|
||||
<Badge variant="secondary">{concern.replace(/_/g, ' ')}</Badge>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if s.notes}
|
||||
<p class="text-sm text-muted-foreground">{s.notes}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">{m["dashboard_noSnapshots"]()}</p>
|
||||
<section class="editorial-hero reveal-1">
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="editorial-title">{m.dashboard_title()}</h2>
|
||||
<p class="editorial-subtitle">{m.dashboard_subtitle()}</p>
|
||||
|
||||
{#if data.latestSnapshot}
|
||||
{@const snapshot = data.latestSnapshot}
|
||||
<div class="hero-strip">
|
||||
<div>
|
||||
<p class="hero-strip-label">{m["dashboard_latestSnapshot"]()}</p>
|
||||
<p class="hero-strip-value">{snapshot.snapshot_date}</p>
|
||||
</div>
|
||||
{#if snapshot.overall_state}
|
||||
<span class={stateTone[snapshot.overall_state] ?? 'state-pill'}>
|
||||
{humanize(snapshot.overall_state)}
|
||||
</span>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Recent routines -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{m["dashboard_recentRoutines"]()}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if data.recentRoutines.length}
|
||||
<ul class="space-y-2">
|
||||
{#each data.recentRoutines as routine (routine.id)}
|
||||
<li class="flex items-center justify-between">
|
||||
<a href="/routines/{routine.id}" class="text-sm hover:underline">
|
||||
{routine.routine_date}
|
||||
</a>
|
||||
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
|
||||
{routine.part_of_day.toUpperCase()}
|
||||
</Badge>
|
||||
</li>
|
||||
<div class="editorial-grid">
|
||||
<section class="editorial-panel reveal-2">
|
||||
<header class="panel-header">
|
||||
<p class="panel-index">01</p>
|
||||
<h3>{m["dashboard_latestSnapshot"]()}</h3>
|
||||
</header>
|
||||
<div class="panel-action-row">
|
||||
<a href={resolve('/skin')} class="panel-action-link">{m["skin_addNew"]()}</a>
|
||||
</div>
|
||||
|
||||
{#if data.latestSnapshot}
|
||||
{@const s = data.latestSnapshot}
|
||||
<div class="snapshot-meta-row">
|
||||
<span class="snapshot-date">{s.snapshot_date}</span>
|
||||
{#if s.overall_state}
|
||||
<span class={stateTone[s.overall_state] ?? 'state-pill'}>{humanize(s.overall_state)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if s.active_concerns.length}
|
||||
<div class="concern-cloud" aria-label={m["skin_activeConcerns"]()}>
|
||||
{#each s.active_concerns as concern (concern)}
|
||||
<span class="concern-chip">{humanize(concern)}</span>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">{m["dashboard_noRoutines"]()}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{#if s.notes}
|
||||
<p class="snapshot-notes">{s.notes}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p class="empty-copy">{m["dashboard_noSnapshots"]()}</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="editorial-panel reveal-3">
|
||||
<header class="panel-header">
|
||||
<p class="panel-index">02</p>
|
||||
<h3>{m["dashboard_recentRoutines"]()}</h3>
|
||||
</header>
|
||||
|
||||
{#if data.recentRoutines.length}
|
||||
<div class="routine-summary-strip">
|
||||
<span class="routine-summary-chip">AM {routineStats.amCount}</span>
|
||||
<span class="routine-summary-chip">PM {routineStats.pmCount}</span>
|
||||
<a href={resolve('/routines/new')} class="routine-summary-link">{m["routines_addNew"]()}</a>
|
||||
</div>
|
||||
<ol class="routine-list">
|
||||
{#each data.recentRoutines as routine (routine.id)}
|
||||
<li class="routine-item">
|
||||
<a href={resolve(`/routines/${routine.id}`)} class="routine-link">
|
||||
<div class="routine-main">
|
||||
<div class="routine-topline">
|
||||
<span class="routine-date">{routine.routine_date}</span>
|
||||
<span class={routineTone[routine.part_of_day] ?? 'routine-pill'}>
|
||||
{routine.part_of_day.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="routine-meta">
|
||||
<span>{routine.steps?.length ?? 0} {m.common_steps()}</span>
|
||||
{#if routine.notes}
|
||||
<span class="routine-note-inline">{m.routines_notes()}: {routine.notes}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
{:else}
|
||||
<p class="empty-copy">{m["dashboard_noRoutines"]()}</p>
|
||||
<div class="empty-actions">
|
||||
<a href={resolve('/routines/new')} class="routine-summary-link">{m["routines_addNew"]()}</a>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@
|
|||
import type { ActionData, PageData } from './$types';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||
import { baseSelectClass } from '$lib/components/forms/form-classes';
|
||||
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
|
||||
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -21,68 +22,67 @@
|
|||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
const flags = ['N', 'ABN', 'POS', 'NEG', 'L', 'H'];
|
||||
const flagColors: Record<string, string> = {
|
||||
N: 'bg-green-100 text-green-800',
|
||||
ABN: 'bg-red-100 text-red-800',
|
||||
POS: 'bg-orange-100 text-orange-800',
|
||||
NEG: 'bg-blue-100 text-blue-800',
|
||||
L: 'bg-yellow-100 text-yellow-800',
|
||||
H: 'bg-red-100 text-red-800'
|
||||
const flagPills: Record<string, string> = {
|
||||
N: 'health-flag-pill health-flag-pill--normal',
|
||||
ABN: 'health-flag-pill health-flag-pill--abnormal',
|
||||
POS: 'health-flag-pill health-flag-pill--positive',
|
||||
NEG: 'health-flag-pill health-flag-pill--negative',
|
||||
L: 'health-flag-pill health-flag-pill--low',
|
||||
H: 'health-flag-pill health-flag-pill--high'
|
||||
};
|
||||
|
||||
let showForm = $state(false);
|
||||
let selectedFlag = $state('');
|
||||
let filterFlag = $derived(data.flag ?? '');
|
||||
|
||||
const flagOptions = flags.map((f) => ({ value: f, label: f }));
|
||||
|
||||
function onFlagChange(v: string) {
|
||||
const base = resolve('/health/lab-results');
|
||||
const url = v ? base + '?flag=' + v : base;
|
||||
goto(url, { replaceState: true });
|
||||
const target = v ? `${base}?flag=${encodeURIComponent(v)}` : base;
|
||||
goto(target, { replaceState: true });
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{m["labResults_title"]()} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold tracking-tight">{m["labResults_title"]()}</h2>
|
||||
<p class="text-muted-foreground">{m["labResults_count"]({ count: data.results.length })}</p>
|
||||
<div class="editorial-page space-y-4">
|
||||
<section class="editorial-hero reveal-1">
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="editorial-title">{m["labResults_title"]()}</h2>
|
||||
<p class="editorial-subtitle">{m["labResults_count"]({ count: data.results.length })}</p>
|
||||
<div class="editorial-toolbar">
|
||||
<Button href={resolve('/health/medications')} variant="outline">{m.medications_title()}</Button>
|
||||
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
||||
{showForm ? m.common_cancel() : m["labResults_addNew"]()}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
||||
{showForm ? m.common_cancel() : m["labResults_addNew"]()}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
||||
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
||||
{/if}
|
||||
{#if form?.created}
|
||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["labResults_added"]()}</div>
|
||||
<div class="editorial-alert editorial-alert--success">{m["labResults_added"]()}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filter -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="editorial-panel reveal-2 flex items-center gap-3">
|
||||
<span class="text-sm text-muted-foreground">{m["labResults_flagFilter"]()}</span>
|
||||
<Select
|
||||
type="single"
|
||||
<select
|
||||
class={`${baseSelectClass} w-32`}
|
||||
value={filterFlag}
|
||||
onValueChange={onFlagChange}
|
||||
onchange={(e) => onFlagChange(e.currentTarget.value)}
|
||||
>
|
||||
<SelectTrigger class="w-32">{filterFlag || m["labResults_flagAll"]()}</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{m["labResults_flagAll"]()}</SelectItem>
|
||||
{#each flags as f (f)}
|
||||
<SelectItem value={f}>{f}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<option value="">{m["labResults_flagAll"]()}</option>
|
||||
{#each flags as f (f)}
|
||||
<option value={f}>{f}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if showForm}
|
||||
<Card>
|
||||
<CardHeader><CardTitle>{m["labResults_newTitle"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<FormSectionCard title={m["labResults_newTitle"]()} className="reveal-2">
|
||||
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<Label for="collected_at">{m["labResults_date"]()}</Label>
|
||||
|
|
@ -108,29 +108,23 @@
|
|||
<Label for="unit_original">{m["labResults_unit"]()}</Label>
|
||||
<Input id="unit_original" name="unit_original" placeholder={m["labResults_unitPlaceholder"]()} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label>{m["labResults_flag"]()}</Label>
|
||||
<input type="hidden" name="flag" value={selectedFlag} />
|
||||
<Select type="single" value={selectedFlag} onValueChange={(v) => (selectedFlag = v)}>
|
||||
<SelectTrigger>{selectedFlag || m["labResults_flagNone"]()}</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">{m["labResults_flagNone"]()}</SelectItem>
|
||||
{#each flags as f (f)}
|
||||
<SelectItem value={f}>{f}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<SimpleSelect
|
||||
id="flag"
|
||||
name="flag"
|
||||
label={m["labResults_flag"]()}
|
||||
options={flagOptions}
|
||||
placeholder={m["labResults_flagNone"]()}
|
||||
bind:value={selectedFlag}
|
||||
/>
|
||||
<div class="flex items-end">
|
||||
<Button type="submit">{m.common_add()}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FormSectionCard>
|
||||
{/if}
|
||||
|
||||
<!-- Desktop: table -->
|
||||
<div class="hidden rounded-md border border-border md:block">
|
||||
<div class="products-table-shell hidden md:block reveal-2">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
|
@ -159,7 +153,7 @@
|
|||
</TableCell>
|
||||
<TableCell>
|
||||
{#if r.flag}
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {flagColors[r.flag] ?? ''}">
|
||||
<span class={flagPills[r.flag] ?? 'health-flag-pill'}>
|
||||
{r.flag}
|
||||
</span>
|
||||
{:else}
|
||||
|
|
@ -180,13 +174,13 @@
|
|||
</div>
|
||||
|
||||
<!-- Mobile: cards -->
|
||||
<div class="flex flex-col gap-3 md:hidden">
|
||||
<div class="flex flex-col gap-3 md:hidden reveal-3">
|
||||
{#each data.results as r (r.record_id)}
|
||||
<div class="rounded-lg border border-border p-4 flex flex-col gap-1">
|
||||
<div class="products-mobile-card flex flex-col gap-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="font-medium">{r.test_name_original ?? r.test_code}</span>
|
||||
{#if r.flag}
|
||||
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {flagColors[r.flag] ?? ''}">
|
||||
<span class={flagPills[r.flag] ?? 'health-flag-pill'}>
|
||||
{r.flag}
|
||||
</span>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
|
||||
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
|
|
@ -15,12 +16,12 @@
|
|||
let showForm = $state(false);
|
||||
let kind = $state('supplement');
|
||||
|
||||
const kindColors: Record<string, string> = {
|
||||
prescription: 'bg-purple-100 text-purple-800',
|
||||
otc: 'bg-blue-100 text-blue-800',
|
||||
supplement: 'bg-green-100 text-green-800',
|
||||
herbal: 'bg-emerald-100 text-emerald-800',
|
||||
other: 'bg-gray-100 text-gray-700'
|
||||
const kindPills: Record<string, string> = {
|
||||
prescription: 'health-kind-pill health-kind-pill--prescription',
|
||||
otc: 'health-kind-pill health-kind-pill--otc',
|
||||
supplement: 'health-kind-pill health-kind-pill--supplement',
|
||||
herbal: 'health-kind-pill health-kind-pill--herbal',
|
||||
other: 'health-kind-pill health-kind-pill--other'
|
||||
};
|
||||
|
||||
const kindLabels: Record<string, () => string> = {
|
||||
|
|
@ -30,44 +31,43 @@
|
|||
herbal: m["medications_kindHerbal"],
|
||||
other: m["medications_kindOther"]
|
||||
};
|
||||
|
||||
const kindOptions = $derived(kinds.map((k) => ({ value: k, label: kindLabels[k]?.() ?? k })));
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{m.medications_title()} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold tracking-tight">{m.medications_title()}</h2>
|
||||
<p class="text-muted-foreground">{m.medications_count({ count: data.medications.length })}</p>
|
||||
<div class="editorial-page space-y-4">
|
||||
<section class="editorial-hero reveal-1">
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="editorial-title">{m.medications_title()}</h2>
|
||||
<p class="editorial-subtitle">{m.medications_count({ count: data.medications.length })}</p>
|
||||
<div class="editorial-toolbar">
|
||||
<Button href={resolve('/health/lab-results')} variant="outline">{m["labResults_title"]()}</Button>
|
||||
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
||||
{showForm ? m.common_cancel() : m["medications_addNew"]()}
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" onclick={() => (showForm = !showForm)}>
|
||||
{showForm ? m.common_cancel() : m["medications_addNew"]()}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
||||
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
||||
{/if}
|
||||
{#if form?.created}
|
||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m.medications_added()}</div>
|
||||
<div class="editorial-alert editorial-alert--success">{m.medications_added()}</div>
|
||||
{/if}
|
||||
|
||||
{#if showForm}
|
||||
<Card>
|
||||
<CardHeader><CardTitle>{m["medications_newTitle"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<FormSectionCard title={m["medications_newTitle"]()} className="reveal-2">
|
||||
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1 col-span-2">
|
||||
<Label>{m.medications_kind()}</Label>
|
||||
<input type="hidden" name="kind" value={kind} />
|
||||
<Select type="single" value={kind} onValueChange={(v) => (kind = v)}>
|
||||
<SelectTrigger>{kindLabels[kind]?.() ?? kind}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each kinds as k (k)}
|
||||
<SelectItem value={k}>{kindLabels[k]?.() ?? k}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div class="col-span-2">
|
||||
<SimpleSelect
|
||||
id="kind"
|
||||
name="kind"
|
||||
label={m.medications_kind()}
|
||||
options={kindOptions}
|
||||
bind:value={kind}
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="product_name">{m["medications_productName"]()}</Label>
|
||||
|
|
@ -85,16 +85,15 @@
|
|||
<Button type="submit">{m.common_add()}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FormSectionCard>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="editorial-panel reveal-2 space-y-3">
|
||||
{#each data.medications as med (med.record_id)}
|
||||
<div class="rounded-md border border-border px-4 py-3">
|
||||
<div class="health-entry-row">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {kindColors[med.kind] ?? ''}">
|
||||
<span class={kindPills[med.kind] ?? 'health-kind-pill'}>
|
||||
{kindLabels[med.kind]?.() ?? med.kind}
|
||||
</span>
|
||||
<span class="font-medium">{med.product_name}</span>
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@
|
|||
}
|
||||
|
||||
function formatTier(value?: string): string {
|
||||
if (!value) return 'n/a';
|
||||
if (!value) return m.common_unknown();
|
||||
if (value === 'budget') return m['productForm_priceBudget']();
|
||||
if (value === 'mid') return m['productForm_priceMid']();
|
||||
if (value === 'premium') return m['productForm_pricePremium']();
|
||||
|
|
@ -125,64 +125,69 @@
|
|||
return (product as Product & { price_per_use_pln?: number }).price_per_use_pln;
|
||||
}
|
||||
|
||||
function formatCategory(value: string): string {
|
||||
return value.replace(/_/g, ' ');
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{m.products_title()} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold tracking-tight">{m.products_title()}</h2>
|
||||
<p class="text-muted-foreground">{m.products_count({ count: totalCount })}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="editorial-page space-y-4">
|
||||
<section class="editorial-hero reveal-1">
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="editorial-title">{m.products_title()}</h2>
|
||||
<p class="editorial-subtitle">{m.products_count({ count: totalCount })}</p>
|
||||
<div class="editorial-toolbar">
|
||||
<Button href={resolve('/products/suggest')} variant="outline"><Sparkles class="size-4" /> {m["products_suggest"]()}</Button>
|
||||
<Button href={resolve('/products/new')}>{m["products_addNew"]()}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}
|
||||
<Button
|
||||
variant={ownershipFilter === f ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => ownershipFilter = f}
|
||||
>
|
||||
{f === 'all' ? m["products_filterAll"]() : f === 'owned' ? m["products_filterOwned"]() : m["products_filterUnowned"]()}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="w-full lg:max-w-md">
|
||||
<Input
|
||||
type="search"
|
||||
bind:value={searchQuery}
|
||||
placeholder={`${m['products_colName']()} / ${m['products_colBrand']()}`}
|
||||
/>
|
||||
<div class="editorial-panel reveal-2">
|
||||
<div class="editorial-filter-row">
|
||||
{#each (['all', 'owned', 'unowned'] as OwnershipFilter[]) as f (f)}
|
||||
<Button
|
||||
variant={ownershipFilter === f ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => ownershipFilter = f}
|
||||
>
|
||||
{f === 'all' ? m["products_filterAll"]() : f === 'owned' ? m["products_filterOwned"]() : m["products_filterUnowned"]()}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button variant={sortKey === 'brand' ? 'default' : 'outline'} size="sm" onclick={() => setSort('brand')}>
|
||||
{m['products_colBrand']()}
|
||||
{#if sortState('brand') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('brand') === 'desc'}<ArrowDown class="size-3" />{/if}
|
||||
</Button>
|
||||
<Button variant={sortKey === 'name' ? 'default' : 'outline'} size="sm" onclick={() => setSort('name')}>
|
||||
{m['products_colName']()}
|
||||
{#if sortState('name') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('name') === 'desc'}<ArrowDown class="size-3" />{/if}
|
||||
</Button>
|
||||
<Button variant={sortKey === 'time' ? 'default' : 'outline'} size="sm" onclick={() => setSort('time')}>
|
||||
{m['products_colTime']()}
|
||||
{#if sortState('time') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('time') === 'desc'}<ArrowDown class="size-3" />{/if}
|
||||
</Button>
|
||||
<Button variant={sortKey === 'price' ? 'default' : 'outline'} size="sm" onclick={() => setSort('price')}>
|
||||
{m["products_colPricePerUse"]()}
|
||||
{#if sortState('price') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('price') === 'desc'}<ArrowDown class="size-3" />{/if}
|
||||
</Button>
|
||||
|
||||
<div class="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="w-full lg:max-w-md">
|
||||
<Input
|
||||
type="search"
|
||||
bind:value={searchQuery}
|
||||
placeholder={`${m['products_colName']()} / ${m['products_colBrand']()}`}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Button variant={sortKey === 'brand' ? 'default' : 'outline'} size="sm" onclick={() => setSort('brand')}>
|
||||
{m['products_colBrand']()}
|
||||
{#if sortState('brand') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('brand') === 'desc'}<ArrowDown class="size-3" />{/if}
|
||||
</Button>
|
||||
<Button variant={sortKey === 'name' ? 'default' : 'outline'} size="sm" onclick={() => setSort('name')}>
|
||||
{m['products_colName']()}
|
||||
{#if sortState('name') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('name') === 'desc'}<ArrowDown class="size-3" />{/if}
|
||||
</Button>
|
||||
<Button variant={sortKey === 'time' ? 'default' : 'outline'} size="sm" onclick={() => setSort('time')}>
|
||||
{m['products_colTime']()}
|
||||
{#if sortState('time') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('time') === 'desc'}<ArrowDown class="size-3" />{/if}
|
||||
</Button>
|
||||
<Button variant={sortKey === 'price' ? 'default' : 'outline'} size="sm" onclick={() => setSort('price')}>
|
||||
{m["products_colPricePerUse"]()}
|
||||
{#if sortState('price') === 'asc'}<ArrowUp class="size-3" />{:else if sortState('price') === 'desc'}<ArrowDown class="size-3" />{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: table -->
|
||||
<div class="hidden rounded-md border border-border md:block">
|
||||
<div class="products-table-shell hidden md:block reveal-2">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
|
@ -202,9 +207,9 @@
|
|||
</TableRow>
|
||||
{:else}
|
||||
{#each groupedProducts as [category, products] (category)}
|
||||
<TableRow class="bg-muted/30 hover:bg-muted/30">
|
||||
<TableRow class="products-category-row">
|
||||
<TableCell colspan={5} class="font-semibold text-sm py-2 text-muted-foreground uppercase tracking-wide">
|
||||
{category.replace(/_/g, ' ')}
|
||||
{formatCategory(category)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{#each products as product (product.id)}
|
||||
|
|
@ -241,18 +246,18 @@
|
|||
</div>
|
||||
|
||||
<!-- Mobile: cards -->
|
||||
<div class="flex flex-col gap-3 md:hidden">
|
||||
<div class="flex flex-col gap-3 md:hidden reveal-3">
|
||||
{#if totalCount === 0}
|
||||
<p class="py-8 text-center text-sm text-muted-foreground">{m["products_noProducts"]()}</p>
|
||||
{:else}
|
||||
{#each groupedProducts as [category, products] (category)}
|
||||
<div class="border-b border-border pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{category.replace(/_/g, ' ')}
|
||||
<div class="products-section-title">
|
||||
{formatCategory(category)}
|
||||
</div>
|
||||
{#each products as product (product.id)}
|
||||
<a
|
||||
href={resolve(`/products/${product.id}`)}
|
||||
class={`block rounded-lg border border-border p-4 ${isOwned(product) ? 'hover:bg-muted/50' : 'bg-muted/20 text-muted-foreground hover:bg-muted/30'}`}
|
||||
class={`products-mobile-card ${isOwned(product) ? 'hover:bg-muted/50' : 'bg-muted/20 text-muted-foreground hover:bg-muted/30'}`}
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
}
|
||||
|
||||
function formatTier(value?: string): string {
|
||||
if (!value) return 'n/a';
|
||||
if (!value) return m.common_unknown();
|
||||
if (value === 'budget') return m['productForm_priceBudget']();
|
||||
if (value === 'mid') return m['productForm_priceMid']();
|
||||
if (value === 'premium') return m['productForm_pricePremium']();
|
||||
|
|
@ -64,7 +64,7 @@
|
|||
|
||||
<svelte:head><title>{product.name} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="fixed inset-x-3 bottom-3 z-40 flex items-center justify-end gap-2 rounded-lg border border-border bg-card/95 p-2 shadow-sm backdrop-blur md:inset-x-auto md:bottom-auto md:right-6 md:top-4">
|
||||
<div class="products-sticky-actions fixed inset-x-3 bottom-3 z-40 flex items-center justify-end gap-2 rounded-lg border border-border bg-card/95 p-2 shadow-sm backdrop-blur md:inset-x-auto md:bottom-auto md:right-6 md:top-4">
|
||||
<Button
|
||||
type="submit"
|
||||
form="product-edit-form"
|
||||
|
|
@ -89,32 +89,29 @@
|
|||
</form>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6 pb-20 md:pb-0">
|
||||
<div class="space-y-2">
|
||||
<a href={resolve('/products')} class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
|
||||
<div class="flex flex-wrap items-start gap-3">
|
||||
<div class="min-w-0 space-y-2">
|
||||
<h2 class="break-words text-2xl font-bold tracking-tight">{product.name}</h2>
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span class="font-medium text-foreground">{product.brand}</span>
|
||||
<span>•</span>
|
||||
<span class="uppercase">{product.recommended_time}</span>
|
||||
<span>•</span>
|
||||
<span>{product.category.replace(/_/g, ' ')}</span>
|
||||
<Badge variant="outline" class="ml-1">ID: {product.id.slice(0, 8)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editorial-page space-y-4 pb-20 md:pb-0">
|
||||
<section class="editorial-panel reveal-1 space-y-3">
|
||||
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="break-words editorial-title">{product.name}</h2>
|
||||
<div class="products-meta-strip">
|
||||
<span class="font-medium text-foreground">{product.brand}</span>
|
||||
<span>•</span>
|
||||
<span class="uppercase">{product.recommended_time}</span>
|
||||
<span>•</span>
|
||||
<span>{product.category.replace(/_/g, ' ')}</span>
|
||||
<Badge variant="outline" class="ml-1">ID: {product.id.slice(0, 8)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
||||
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
||||
{/if}
|
||||
{#if form?.success}
|
||||
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{m.common_saved()}</div>
|
||||
<div class="editorial-alert editorial-alert--success">{m.common_saved()}</div>
|
||||
{/if}
|
||||
|
||||
<Tabs bind:value={activeTab} class="space-y-2">
|
||||
<Tabs bind:value={activeTab} class="products-tabs space-y-2 reveal-2">
|
||||
<TabsList class="h-auto w-full justify-start gap-1 overflow-x-auto p-1 whitespace-nowrap">
|
||||
<TabsTrigger value="inventory" class="shrink-0 px-3" title={m.inventory_title({ count: product.inventory.length })}>
|
||||
<Boxes class="size-4" aria-hidden="true" />
|
||||
|
|
@ -136,13 +133,13 @@
|
|||
</div>
|
||||
|
||||
{#if form?.inventoryAdded}
|
||||
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{m["inventory_packageAdded"]()}</div>
|
||||
<div class="editorial-alert editorial-alert--success">{m["inventory_packageAdded"]()}</div>
|
||||
{/if}
|
||||
{#if form?.inventoryUpdated}
|
||||
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{m["inventory_packageUpdated"]()}</div>
|
||||
<div class="editorial-alert editorial-alert--success">{m["inventory_packageUpdated"]()}</div>
|
||||
{/if}
|
||||
{#if form?.inventoryDeleted}
|
||||
<div class="rounded-md border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">{m["inventory_packageDeleted"]()}</div>
|
||||
<div class="editorial-alert editorial-alert--success">{m["inventory_packageDeleted"]()}</div>
|
||||
{/if}
|
||||
|
||||
{#if showInventoryForm}
|
||||
|
|
@ -334,7 +331,7 @@
|
|||
<ProductForm
|
||||
bind:this={productFormRef}
|
||||
{product}
|
||||
bind:dirty={isEditDirty}
|
||||
onDirtyChange={(dirty) => (isEditDirty = dirty)}
|
||||
saveVersion={editSaveVersion}
|
||||
showAiTrigger={false}
|
||||
computedPriceLabel={formatAmount(getPriceAmount(), getPriceCurrency())}
|
||||
|
|
|
|||
|
|
@ -20,19 +20,20 @@
|
|||
|
||||
<svelte:head><title>{m["products_newTitle"]()} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="max-w-2xl space-y-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href={resolve('/products')} class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
|
||||
<h2 class="text-2xl font-bold tracking-tight">{m["products_newTitle"]()}</h2>
|
||||
</div>
|
||||
<div class="editorial-page space-y-4">
|
||||
<section class="editorial-panel reveal-1 space-y-3">
|
||||
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="editorial-title">{m["products_newTitle"]()}</h2>
|
||||
</section>
|
||||
|
||||
{#if form?.error}
|
||||
<div bind:this={errorEl} class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
<div bind:this={errorEl} class="editorial-alert editorial-alert--error">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" use:enhance class="space-y-6">
|
||||
<form method="POST" use:enhance class="editorial-panel reveal-2 space-y-6 p-4">
|
||||
<ProductForm />
|
||||
|
||||
<div class="flex gap-3 pb-6">
|
||||
|
|
|
|||
|
|
@ -32,17 +32,18 @@
|
|||
|
||||
<svelte:head><title>{m["products_suggestTitle"]()} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="max-w-2xl space-y-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href={resolve('/products')} class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
|
||||
<h2 class="text-2xl font-bold tracking-tight">{m["products_suggestTitle"]()}</h2>
|
||||
</div>
|
||||
<div class="editorial-page space-y-4">
|
||||
<section class="editorial-panel reveal-1 space-y-3">
|
||||
<a href={resolve('/products')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["products_backToList"]()}</a>
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="editorial-title">{m["products_suggestTitle"]()}</h2>
|
||||
</section>
|
||||
|
||||
{#if errorMsg}
|
||||
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{errorMsg}</div>
|
||||
<div class="editorial-alert editorial-alert--error">{errorMsg}</div>
|
||||
{/if}
|
||||
|
||||
<Card>
|
||||
<Card class="reveal-2">
|
||||
<CardHeader><CardTitle class="text-base">{m["products_suggestSubtitle"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<form method="POST" action="?/suggest" use:enhance={enhanceForm} class="space-y-4">
|
||||
|
|
@ -63,14 +64,14 @@
|
|||
|
||||
{#if suggestions && suggestions.length > 0}
|
||||
{#if reasoning}
|
||||
<Card class="border-muted bg-muted/30">
|
||||
<Card class="border-muted bg-muted/30 reveal-3">
|
||||
<CardContent class="pt-4">
|
||||
<p class="text-sm text-muted-foreground italic">{reasoning}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-4 reveal-3">
|
||||
<h3 class="text-lg font-semibold">{m["products_suggestResults"]()}</h3>
|
||||
{#each suggestions as s (s.product_type)}
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -22,28 +22,27 @@
|
|||
|
||||
<svelte:head><title>{m.routines_title()} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold tracking-tight">{m.routines_title()}</h2>
|
||||
<p class="text-muted-foreground">{m.routines_count({ count: data.routines.length })}</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="editorial-page space-y-4">
|
||||
<section class="editorial-hero reveal-1">
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="editorial-title">{m.routines_title()}</h2>
|
||||
<p class="editorial-subtitle">{m.routines_count({ count: data.routines.length })}</p>
|
||||
<div class="editorial-toolbar">
|
||||
<Button href={resolve('/routines/suggest')} variant="outline">{m["routines_suggestAI"]()}</Button>
|
||||
<Button href={resolve('/routines/new')}>{m["routines_addNew"]()}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if sortedDates.length}
|
||||
<div class="space-y-4">
|
||||
{#each sortedDates as date}
|
||||
<div class="editorial-panel reveal-2 space-y-4">
|
||||
{#each sortedDates as date (date)}
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-muted-foreground mb-2">{date}</h3>
|
||||
<h3 class="products-section-title mb-2">{date}</h3>
|
||||
<div class="space-y-1">
|
||||
{#each byDate[date] as routine}
|
||||
{#each byDate[date] as routine (routine.id)}
|
||||
<a
|
||||
href="/routines/{routine.id}"
|
||||
class="flex items-center justify-between rounded-md border border-border px-4 py-3 hover:bg-muted/50 transition-colors"
|
||||
href={resolve(`/routines/${routine.id}`)}
|
||||
class="routine-ledger-row"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
|
||||
|
|
@ -61,6 +60,8 @@
|
|||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-sm text-muted-foreground">{m["routines_noRoutines"]()}</p>
|
||||
<div class="editorial-panel reveal-2">
|
||||
<p class="empty-copy">{m["routines_noRoutines"]()}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import { dragHandleZone, dragHandle, type DndEvent } from 'svelte-dnd-action';
|
||||
import { updateRoutineStep } from '$lib/api';
|
||||
import type { GroomingAction, RoutineStep } from '$lib/types';
|
||||
|
|
@ -10,14 +11,8 @@
|
|||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectGroupHeading,
|
||||
SelectItem,
|
||||
SelectTrigger
|
||||
} from '$lib/components/ui/select';
|
||||
import GroupedSelect from '$lib/components/forms/GroupedSelect.svelte';
|
||||
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { GripVertical, Pencil, X, ArrowLeft } from 'lucide-svelte';
|
||||
|
|
@ -137,21 +132,36 @@
|
|||
.filter((c) => groups.has(c))
|
||||
.map((c) => [c, groups.get(c)!] as const);
|
||||
});
|
||||
|
||||
const groupedProductOptions = $derived(
|
||||
groupedProducts.map(([cat, items]) => ({
|
||||
label: formatCategory(cat),
|
||||
options: items.map((p) => ({ value: p.id, label: `${p.name} · ${p.brand}` }))
|
||||
}))
|
||||
);
|
||||
|
||||
const groomingActionOptions = GROOMING_ACTIONS.map((action) => ({
|
||||
value: action,
|
||||
label: action.replace(/_/g, ' ')
|
||||
}));
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="max-w-2xl space-y-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/routines" class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
|
||||
<h2 class="text-2xl font-bold tracking-tight">{routine.routine_date}</h2>
|
||||
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
|
||||
{routine.part_of_day.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="editorial-page space-y-4">
|
||||
<section class="editorial-panel reveal-1 space-y-3">
|
||||
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<h2 class="editorial-title text-[clamp(1.8rem,3vw,2.4rem)]">{routine.routine_date}</h2>
|
||||
<Badge variant={routine.part_of_day === 'am' ? 'default' : 'secondary'}>
|
||||
{routine.part_of_day.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
||||
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
{#if routine.notes}
|
||||
|
|
@ -159,7 +169,7 @@
|
|||
{/if}
|
||||
|
||||
<!-- Steps -->
|
||||
<div class="space-y-3">
|
||||
<div class="editorial-panel reveal-2 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">{m.routines_steps({ count: steps.length })}</h3>
|
||||
<Button variant="outline" size="sm" onclick={() => (showStepForm = !showStepForm)}>
|
||||
|
|
@ -172,29 +182,14 @@
|
|||
<CardHeader><CardTitle class="text-base">{m["routines_addStepTitle"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<form method="POST" action="?/addStep" use:enhance class="space-y-4">
|
||||
<div class="space-y-1">
|
||||
<Label>{m.routines_product()}</Label>
|
||||
<input type="hidden" name="product_id" value={selectedProductId} />
|
||||
<Select type="single" value={selectedProductId} onValueChange={(v) => (selectedProductId = v)}>
|
||||
<SelectTrigger>
|
||||
{#if selectedProductId}
|
||||
{products.find((p) => p.id === selectedProductId)?.name ?? m["routines_selectProduct"]()}
|
||||
{:else}
|
||||
{m["routines_selectProduct"]()}
|
||||
{/if}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each groupedProducts as [cat, items]}
|
||||
<SelectGroup>
|
||||
<SelectGroupHeading>{formatCategory(cat)}</SelectGroupHeading>
|
||||
{#each items as p (p.id)}
|
||||
<SelectItem value={p.id}>{p.name} · {p.brand}</SelectItem>
|
||||
{/each}
|
||||
</SelectGroup>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<GroupedSelect
|
||||
id="new_step_product"
|
||||
name="product_id"
|
||||
label={m.routines_product()}
|
||||
groups={groupedProductOptions}
|
||||
placeholder={m["routines_selectProduct"]()}
|
||||
bind:value={selectedProductId}
|
||||
/>
|
||||
<input type="hidden" name="order_index" value={nextOrderIndex} />
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1">
|
||||
|
|
@ -226,32 +221,14 @@
|
|||
<div class="px-4 py-3 space-y-3">
|
||||
{#if step.product_id !== undefined}
|
||||
<!-- Product step: change product / dose / region -->
|
||||
<div class="space-y-1">
|
||||
<Label>{m.routines_product()}</Label>
|
||||
<Select
|
||||
type="single"
|
||||
value={editDraft.product_id ?? ''}
|
||||
onValueChange={(v) => (editDraft.product_id = v || undefined)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{#if editDraft.product_id}
|
||||
{products.find((p) => p.id === editDraft.product_id)?.name ?? m["routines_selectProduct"]()}
|
||||
{:else}
|
||||
{m["routines_selectProduct"]()}
|
||||
{/if}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each groupedProducts as [cat, items]}
|
||||
<SelectGroup>
|
||||
<SelectGroupHeading>{formatCategory(cat)}</SelectGroupHeading>
|
||||
{#each items as p (p.id)}
|
||||
<SelectItem value={p.id}>{p.name} · {p.brand}</SelectItem>
|
||||
{/each}
|
||||
</SelectGroup>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<GroupedSelect
|
||||
id={`edit_step_product_${step.id}`}
|
||||
label={m.routines_product()}
|
||||
groups={groupedProductOptions}
|
||||
placeholder={m["routines_selectProduct"]()}
|
||||
value={editDraft.product_id ?? ''}
|
||||
onChange={(value) => (editDraft.product_id = value || undefined)}
|
||||
/>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1">
|
||||
<Label>{m.routines_dose()}</Label>
|
||||
|
|
@ -272,24 +249,15 @@
|
|||
</div>
|
||||
{:else}
|
||||
<!-- Action step: change action_type / notes -->
|
||||
<div class="space-y-1">
|
||||
<Label>{m["routines_action"]()}</Label>
|
||||
<Select
|
||||
type="single"
|
||||
value={editDraft.action_type ?? ''}
|
||||
onValueChange={(v) =>
|
||||
(editDraft.action_type = (v || undefined) as GroomingAction | undefined)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
{editDraft.action_type?.replace(/_/g, ' ') ?? m["routines_selectAction"]()}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each GROOMING_ACTIONS as action (action)}
|
||||
<SelectItem value={action}>{action.replace(/_/g, ' ')}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<SimpleSelect
|
||||
id={`edit_step_action_${step.id}`}
|
||||
label={m["routines_action"]()}
|
||||
options={groomingActionOptions}
|
||||
placeholder={m["routines_selectAction"]()}
|
||||
value={editDraft.action_type ?? ''}
|
||||
onChange={(value) =>
|
||||
(editDraft.action_type = (value || undefined) as GroomingAction | undefined)}
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<Label>{m.routines_notes()}</Label>
|
||||
<Input
|
||||
|
|
@ -362,7 +330,7 @@
|
|||
{/if}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator class="opacity-50" />
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
|
|
@ -46,28 +47,31 @@
|
|||
|
||||
<svelte:head><title>{m.grooming_title()} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="max-w-2xl space-y-6">
|
||||
<div class="editorial-page space-y-4">
|
||||
<section class="editorial-panel reveal-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<a href="/routines" class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["grooming_backToRoutines"]()}</a>
|
||||
<h2 class="mt-1 text-2xl font-bold tracking-tight">{m.grooming_title()}</h2>
|
||||
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["grooming_backToRoutines"]()}</a>
|
||||
<p class="editorial-kicker mt-2">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="editorial-title mt-1 text-[clamp(1.8rem,3vw,2.4rem)]">{m.grooming_title()}</h2>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onclick={() => (showAddForm = !showAddForm)}>
|
||||
{showAddForm ? m.common_cancel() : m["grooming_addEntry"]()}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
||||
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
||||
{/if}
|
||||
{#if form?.created}
|
||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["grooming_entryAdded"]()}</div>
|
||||
<div class="editorial-alert editorial-alert--success">{m["grooming_entryAdded"]()}</div>
|
||||
{/if}
|
||||
{#if form?.updated}
|
||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["grooming_entryUpdated"]()}</div>
|
||||
<div class="editorial-alert editorial-alert--success">{m["grooming_entryUpdated"]()}</div>
|
||||
{/if}
|
||||
{#if form?.deleted}
|
||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["grooming_entryDeleted"]()}</div>
|
||||
<div class="editorial-alert editorial-alert--success">{m["grooming_entryDeleted"]()}</div>
|
||||
{/if}
|
||||
|
||||
<!-- Add form -->
|
||||
|
|
|
|||
|
|
@ -1,50 +1,51 @@
|
|||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import type { ActionData, PageData } from './$types';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ArrowLeft } from 'lucide-svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
|
||||
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let partOfDay = $state('am');
|
||||
|
||||
const partOfDayOptions = [
|
||||
{ value: 'am', label: m.common_am() },
|
||||
{ value: 'pm', label: m.common_pm() }
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{m["routines_newTitle"]()} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="max-w-md space-y-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/routines" class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
|
||||
<h2 class="text-2xl font-bold tracking-tight">{m["routines_newTitle"]()}</h2>
|
||||
</div>
|
||||
<div class="editorial-page space-y-4">
|
||||
<section class="editorial-panel reveal-1 space-y-3">
|
||||
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["routines_backToList"]()}</a>
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="editorial-title">{m["routines_newTitle"]()}</h2>
|
||||
</section>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
||||
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
||||
{/if}
|
||||
|
||||
<Card>
|
||||
<CardHeader><CardTitle>{m["routines_detailsTitle"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<FormSectionCard title={m["routines_detailsTitle"]()} className="reveal-2">
|
||||
<form method="POST" use:enhance class="space-y-5">
|
||||
<div class="space-y-2">
|
||||
<Label for="routine_date">{m.routines_date()}</Label>
|
||||
<Input id="routine_date" name="routine_date" type="date" value={data.today} required />
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{m["routines_amOrPm"]()}</Label>
|
||||
<input type="hidden" name="part_of_day" value={partOfDay} />
|
||||
<Select type="single" value={partOfDay} onValueChange={(v) => (partOfDay = v)}>
|
||||
<SelectTrigger>{partOfDay.toUpperCase()}</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="am">{m.common_am()}</SelectItem>
|
||||
<SelectItem value="pm">{m.common_pm()}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<SimpleSelect
|
||||
id="part_of_day"
|
||||
name="part_of_day"
|
||||
label={m["routines_amOrPm"]()}
|
||||
options={partOfDayOptions}
|
||||
bind:value={partOfDay}
|
||||
/>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="notes">{m.routines_notes()}</Label>
|
||||
|
|
@ -53,9 +54,8 @@
|
|||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<Button type="submit">{m["routines_createRoutine"]()}</Button>
|
||||
<Button variant="outline" href="/routines">{m.common_cancel()}</Button>
|
||||
<Button variant="outline" href={resolve('/routines')}>{m.common_cancel()}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FormSectionCard>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,10 +7,13 @@
|
|||
import { m } from '$lib/paraglide/messages.js';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Card, CardContent } from '$lib/components/ui/card';
|
||||
import FormSectionCard from '$lib/components/forms/FormSectionCard.svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||
import { baseTextareaClass } from '$lib/components/forms/form-classes';
|
||||
import HintCheckbox from '$lib/components/forms/HintCheckbox.svelte';
|
||||
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
|
||||
import { ChevronUp, ChevronDown, ArrowLeft } from 'lucide-svelte';
|
||||
|
||||
|
|
@ -34,6 +37,13 @@
|
|||
let loadingBatch = $state(false);
|
||||
let loadingSave = $state(false);
|
||||
|
||||
const textareaClass = `${baseTextareaClass} resize-none`;
|
||||
|
||||
const partOfDayOptions = [
|
||||
{ value: 'am', label: m["suggest_amMorning"]() },
|
||||
{ value: 'pm', label: m["suggest_pmEvening"]() }
|
||||
];
|
||||
|
||||
function stepLabel(step: SuggestedStep): string {
|
||||
if (step.product_id && productMap[step.product_id]) {
|
||||
const p = productMap[step.product_id];
|
||||
|
|
@ -104,17 +114,18 @@
|
|||
|
||||
<svelte:head><title>{m.suggest_title()} — innercontext</title></svelte:head>
|
||||
|
||||
<div class="max-w-2xl space-y-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<a href={resolve('/routines')} class="inline-flex items-center gap-1 text-sm text-muted-foreground hover:underline"><ArrowLeft class="size-4" /> {m["suggest_backToRoutines"]()}</a>
|
||||
<h2 class="text-2xl font-bold tracking-tight">{m.suggest_title()}</h2>
|
||||
</div>
|
||||
<div class="editorial-page space-y-4">
|
||||
<section class="editorial-panel reveal-1 space-y-3">
|
||||
<a href={resolve('/routines')} class="editorial-backlink"><ArrowLeft class="size-4" /> {m["suggest_backToRoutines"]()}</a>
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="editorial-title">{m.suggest_title()}</h2>
|
||||
</section>
|
||||
|
||||
{#if errorMsg}
|
||||
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{errorMsg}</div>
|
||||
<div class="editorial-alert editorial-alert--error">{errorMsg}</div>
|
||||
{/if}
|
||||
|
||||
<Tabs value="single">
|
||||
<Tabs value="single" class="reveal-2 editorial-tabs">
|
||||
<TabsList class="w-full">
|
||||
<TabsTrigger value="single" class="flex-1" onclick={() => { errorMsg = null; }}>{m["suggest_singleTab"]()}</TabsTrigger>
|
||||
<TabsTrigger value="batch" class="flex-1" onclick={() => { errorMsg = null; }}>{m["suggest_batchTab"]()}</TabsTrigger>
|
||||
|
|
@ -122,26 +133,20 @@
|
|||
|
||||
<!-- ── Single tab ─────────────────────────────────────────────────── -->
|
||||
<TabsContent value="single" class="space-y-6 pt-4">
|
||||
<Card>
|
||||
<CardHeader><CardTitle class="text-base">{m["suggest_singleParams"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<FormSectionCard title={m["suggest_singleParams"]()}>
|
||||
<form method="POST" action="?/suggest" use:enhance={enhanceSingle} class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="single_date">{m.suggest_date()}</Label>
|
||||
<Input id="single_date" name="routine_date" type="date" value={data.today} required />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{m["suggest_timeOfDay"]()}</Label>
|
||||
<input type="hidden" name="part_of_day" value={partOfDay} />
|
||||
<Select type="single" value={partOfDay} onValueChange={(v) => (partOfDay = v as 'am' | 'pm')}>
|
||||
<SelectTrigger>{partOfDay.toUpperCase()}</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="am">{m["suggest_amMorning"]()}</SelectItem>
|
||||
<SelectItem value="pm">{m["suggest_pmEvening"]()}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<SimpleSelect
|
||||
id="single_part_of_day"
|
||||
name="part_of_day"
|
||||
label={m["suggest_timeOfDay"]()}
|
||||
options={partOfDayOptions}
|
||||
bind:value={partOfDay}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
|
|
@ -151,35 +156,23 @@
|
|||
name="notes"
|
||||
rows="2"
|
||||
placeholder={m["suggest_contextPlaceholder"]()}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
|
||||
class={textareaClass}
|
||||
></textarea>
|
||||
</div>
|
||||
{#if partOfDay === 'am'}
|
||||
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
|
||||
<input
|
||||
<HintCheckbox
|
||||
id="single_leaving_home"
|
||||
name="leaving_home"
|
||||
type="checkbox"
|
||||
class="mt-0.5 h-4 w-4 rounded border-input"
|
||||
label={m["suggest_leavingHomeLabel"]()}
|
||||
hint={m["suggest_leavingHomeHint"]()}
|
||||
/>
|
||||
<div class="space-y-0.5">
|
||||
<Label for="single_leaving_home" class="font-medium">{m["suggest_leavingHomeLabel"]()}</Label>
|
||||
<p class="text-xs text-muted-foreground">{m["suggest_leavingHomeHint"]()}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
|
||||
<input
|
||||
id="single_include_minoxidil_beard"
|
||||
name="include_minoxidil_beard"
|
||||
type="checkbox"
|
||||
class="mt-0.5 h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<div class="space-y-0.5">
|
||||
<Label for="single_include_minoxidil_beard" class="font-medium">{m["suggest_minoxidilToggleLabel"]()}</Label>
|
||||
<p class="text-xs text-muted-foreground">{m["suggest_minoxidilToggleHint"]()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<HintCheckbox
|
||||
id="single_include_minoxidil_beard"
|
||||
name="include_minoxidil_beard"
|
||||
label={m["suggest_minoxidilToggleLabel"]()}
|
||||
hint={m["suggest_minoxidilToggleHint"]()}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={loadingSingle} class="w-full">
|
||||
{#if loadingSingle}
|
||||
|
|
@ -190,8 +183,7 @@
|
|||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FormSectionCard>
|
||||
|
||||
{#if suggestion}
|
||||
<div class="space-y-4">
|
||||
|
|
@ -272,9 +264,7 @@
|
|||
|
||||
<!-- ── Batch tab ──────────────────────────────────────────────────── -->
|
||||
<TabsContent value="batch" class="space-y-6 pt-4">
|
||||
<Card>
|
||||
<CardHeader><CardTitle class="text-base">{m["suggest_batchRange"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<FormSectionCard title={m["suggest_batchRange"]()}>
|
||||
<form id="batch-form" method="POST" action="?/suggestBatch" use:enhance={enhanceBatch} class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
|
|
@ -294,33 +284,21 @@
|
|||
name="notes"
|
||||
rows="2"
|
||||
placeholder={m["suggest_batchContextPlaceholder"]()}
|
||||
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring resize-none"
|
||||
class={textareaClass}
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
|
||||
<input
|
||||
id="batch_include_minoxidil_beard"
|
||||
name="include_minoxidil_beard"
|
||||
type="checkbox"
|
||||
class="mt-0.5 h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<div class="space-y-0.5">
|
||||
<Label for="batch_include_minoxidil_beard" class="font-medium">{m["suggest_minoxidilToggleLabel"]()}</Label>
|
||||
<p class="text-xs text-muted-foreground">{m["suggest_minoxidilToggleHint"]()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3 rounded-md border border-border px-3 py-2">
|
||||
<input
|
||||
id="batch_minimize_products"
|
||||
name="minimize_products"
|
||||
type="checkbox"
|
||||
class="mt-0.5 h-4 w-4 rounded border-input"
|
||||
/>
|
||||
<div class="space-y-0.5">
|
||||
<Label for="batch_minimize_products" class="font-medium">{m["suggest_minimizeProductsLabel"]()}</Label>
|
||||
<p class="text-xs text-muted-foreground">{m["suggest_minimizeProductsHint"]()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<HintCheckbox
|
||||
id="batch_include_minoxidil_beard"
|
||||
name="include_minoxidil_beard"
|
||||
label={m["suggest_minoxidilToggleLabel"]()}
|
||||
hint={m["suggest_minoxidilToggleHint"]()}
|
||||
/>
|
||||
<HintCheckbox
|
||||
id="batch_minimize_products"
|
||||
name="minimize_products"
|
||||
label={m["suggest_minimizeProductsLabel"]()}
|
||||
hint={m["suggest_minimizeProductsHint"]()}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={loadingBatch} class="w-full">
|
||||
{#if loadingBatch}
|
||||
|
|
@ -331,8 +309,7 @@
|
|||
{/if}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FormSectionCard>
|
||||
|
||||
{#if batch}
|
||||
<div class="space-y-4">
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@
|
|||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/components/ui/select';
|
||||
import LabeledInputField from '$lib/components/forms/LabeledInputField.svelte';
|
||||
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
|
||||
import { Sparkles, Pencil, X } from 'lucide-svelte';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
|
@ -18,11 +17,11 @@
|
|||
const barrierStates = ['intact', 'mildly_compromised', 'compromised'];
|
||||
const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone'];
|
||||
|
||||
const stateColors: Record<string, string> = {
|
||||
excellent: 'bg-green-100 text-green-800',
|
||||
good: 'bg-blue-100 text-blue-800',
|
||||
fair: 'bg-yellow-100 text-yellow-800',
|
||||
poor: 'bg-red-100 text-red-800'
|
||||
const statePills: Record<string, string> = {
|
||||
excellent: 'state-pill state-pill--excellent',
|
||||
good: 'state-pill state-pill--good',
|
||||
fair: 'state-pill state-pill--fair',
|
||||
poor: 'state-pill state-pill--poor'
|
||||
};
|
||||
|
||||
const stateLabels: Record<string, () => string> = {
|
||||
|
|
@ -109,6 +108,11 @@
|
|||
let aiLoading = $state(false);
|
||||
let aiError = $state('');
|
||||
|
||||
const overallStateOptions = $derived(states.map((s) => ({ value: s, label: stateLabels[s]?.() ?? s })));
|
||||
const textureOptions = $derived(skinTextures.map((t) => ({ value: t, label: textureLabels[t]?.() ?? t })));
|
||||
const skinTypeOptions = $derived(skinTypes.map((st) => ({ value: st, label: skinTypeLabels[st]?.() ?? st })));
|
||||
const barrierOptions = $derived(barrierStates.map((b) => ({ value: b, label: barrierLabels[b]?.() ?? b })));
|
||||
|
||||
const sortedSnapshots = $derived(
|
||||
[...data.snapshots].sort((a, b) => b.snapshot_date.localeCompare(a.snapshot_date))
|
||||
);
|
||||
|
|
@ -168,13 +172,12 @@
|
|||
<svelte:head><title>{m.skin_title()} — innercontext</title></svelte:head>
|
||||
<svelte:window onkeydown={handleModalKeydown} />
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold tracking-tight">{m.skin_title()}</h2>
|
||||
<p class="text-muted-foreground">{m.skin_count({ count: data.snapshots.length })}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="editorial-page space-y-4">
|
||||
<section class="editorial-hero reveal-1">
|
||||
<p class="editorial-kicker">{m["nav_appSubtitle"]()}</p>
|
||||
<h2 class="editorial-title">{m.skin_title()}</h2>
|
||||
<p class="editorial-subtitle">{m.skin_count({ count: data.snapshots.length })}</p>
|
||||
<div class="editorial-toolbar">
|
||||
{#if showForm}
|
||||
<Button type="button" variant="outline" size="sm" onclick={openAiModal}>
|
||||
<Sparkles class="size-4" />
|
||||
|
|
@ -185,19 +188,19 @@
|
|||
{showForm ? m.common_cancel() : m["skin_addNew"]()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="rounded-md bg-destructive/10 px-4 py-3 text-sm text-destructive">{form.error}</div>
|
||||
<div class="editorial-alert editorial-alert--error">{form.error}</div>
|
||||
{/if}
|
||||
{#if form?.created}
|
||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["skin_snapshotAdded"]()}</div>
|
||||
<div class="editorial-alert editorial-alert--success">{m["skin_snapshotAdded"]()}</div>
|
||||
{/if}
|
||||
{#if form?.updated}
|
||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["skin_snapshotUpdated"]()}</div>
|
||||
<div class="editorial-alert editorial-alert--success">{m["skin_snapshotUpdated"]()}</div>
|
||||
{/if}
|
||||
{#if form?.deleted}
|
||||
<div class="rounded-md bg-green-50 px-4 py-3 text-sm text-green-700">{m["skin_snapshotDeleted"]()}</div>
|
||||
<div class="editorial-alert editorial-alert--success">{m["skin_snapshotDeleted"]()}</div>
|
||||
{/if}
|
||||
|
||||
{#if showForm}
|
||||
|
|
@ -249,96 +252,109 @@
|
|||
{/if}
|
||||
|
||||
<!-- New snapshot form -->
|
||||
<Card>
|
||||
<Card class="reveal-2">
|
||||
<CardHeader><CardTitle>{m["skin_newSnapshotTitle"]()}</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
<form method="POST" action="?/create" use:enhance class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<Label for="snapshot_date">{m.skin_date()}</Label>
|
||||
<Input
|
||||
id="snapshot_date"
|
||||
name="snapshot_date"
|
||||
type="date"
|
||||
bind:value={snapshotDate}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label>{m["skin_overallState"]()}</Label>
|
||||
<input type="hidden" name="overall_state" value={overallState} />
|
||||
<Select type="single" value={overallState} onValueChange={(v) => (overallState = v)}>
|
||||
<SelectTrigger>{overallState ? stateLabels[overallState]?.() ?? overallState : m.common_select()}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each states as s (s)}
|
||||
<SelectItem value={s}>{stateLabels[s]?.() ?? s}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label>{m.skin_texture()}</Label>
|
||||
<input type="hidden" name="texture" value={texture} />
|
||||
<Select type="single" value={texture} onValueChange={(v) => (texture = v)}>
|
||||
<SelectTrigger>{texture ? textureLabels[texture]?.() ?? texture : m.common_select()}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each skinTextures as t (t)}
|
||||
<SelectItem value={t}>{textureLabels[t]?.() ?? t}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label>{m["skin_skinType"]()}</Label>
|
||||
<input type="hidden" name="skin_type" value={skinType} />
|
||||
<Select type="single" value={skinType} onValueChange={(v) => (skinType = v)}>
|
||||
<SelectTrigger>{skinType ? skinTypeLabels[skinType]?.() ?? skinType : m.common_select()}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each skinTypes as st (st)}
|
||||
<SelectItem value={st}>{skinTypeLabels[st]?.() ?? st}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label>{m["skin_barrierState"]()}</Label>
|
||||
<input type="hidden" name="barrier_state" value={barrierState} />
|
||||
<Select type="single" value={barrierState} onValueChange={(v) => (barrierState = v)}>
|
||||
<SelectTrigger>{barrierState ? barrierLabels[barrierState]?.() ?? barrierState : m.common_select()}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each barrierStates as b (b)}
|
||||
<SelectItem value={b}>{barrierLabels[b]?.() ?? b}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="hydration_level">{m.skin_hydration()}</Label>
|
||||
<Input id="hydration_level" name="hydration_level" type="number" min="1" max="5" bind:value={hydrationLevel} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="sensitivity_level">{m.skin_sensitivity()}</Label>
|
||||
<Input id="sensitivity_level" name="sensitivity_level" type="number" min="1" max="5" bind:value={sensitivityLevel} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="sebum_tzone">{m["skin_sebumTzone"]()}</Label>
|
||||
<Input id="sebum_tzone" name="sebum_tzone" type="number" min="1" max="5" bind:value={sebumTzone} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="sebum_cheeks">{m["skin_sebumCheeks"]()}</Label>
|
||||
<Input id="sebum_cheeks" name="sebum_cheeks" type="number" min="1" max="5" bind:value={sebumCheeks} />
|
||||
</div>
|
||||
<div class="space-y-1 col-span-2">
|
||||
<Label for="active_concerns">{m["skin_activeConcerns"]()}</Label>
|
||||
<Input id="active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={activeConcernsRaw} />
|
||||
</div>
|
||||
<div class="space-y-1 col-span-2">
|
||||
<Label for="priorities">{m["skin_priorities"]()}</Label>
|
||||
<Input id="priorities" name="priorities" placeholder={m["skin_prioritiesPlaceholder"]()} bind:value={prioritiesRaw} />
|
||||
</div>
|
||||
<div class="space-y-1 col-span-2">
|
||||
<Label for="notes">{m.skin_notes()}</Label>
|
||||
<Input id="notes" name="notes" bind:value={notes} />
|
||||
</div>
|
||||
<LabeledInputField
|
||||
id="snapshot_date"
|
||||
name="snapshot_date"
|
||||
label={m.skin_date()}
|
||||
type="date"
|
||||
required
|
||||
bind:value={snapshotDate}
|
||||
/>
|
||||
<SimpleSelect
|
||||
id="overall_state"
|
||||
name="overall_state"
|
||||
label={m["skin_overallState"]()}
|
||||
options={overallStateOptions}
|
||||
placeholder={m.common_select()}
|
||||
bind:value={overallState}
|
||||
/>
|
||||
<SimpleSelect
|
||||
id="texture"
|
||||
name="texture"
|
||||
label={m.skin_texture()}
|
||||
options={textureOptions}
|
||||
placeholder={m.common_select()}
|
||||
bind:value={texture}
|
||||
/>
|
||||
<SimpleSelect
|
||||
id="skin_type"
|
||||
name="skin_type"
|
||||
label={m["skin_skinType"]()}
|
||||
options={skinTypeOptions}
|
||||
placeholder={m.common_select()}
|
||||
bind:value={skinType}
|
||||
/>
|
||||
<SimpleSelect
|
||||
id="barrier_state"
|
||||
name="barrier_state"
|
||||
label={m["skin_barrierState"]()}
|
||||
options={barrierOptions}
|
||||
placeholder={m.common_select()}
|
||||
bind:value={barrierState}
|
||||
/>
|
||||
<LabeledInputField
|
||||
id="hydration_level"
|
||||
name="hydration_level"
|
||||
label={m.skin_hydration()}
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
bind:value={hydrationLevel}
|
||||
/>
|
||||
<LabeledInputField
|
||||
id="sensitivity_level"
|
||||
name="sensitivity_level"
|
||||
label={m.skin_sensitivity()}
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
bind:value={sensitivityLevel}
|
||||
/>
|
||||
<LabeledInputField
|
||||
id="sebum_tzone"
|
||||
name="sebum_tzone"
|
||||
label={m["skin_sebumTzone"]()}
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
bind:value={sebumTzone}
|
||||
/>
|
||||
<LabeledInputField
|
||||
id="sebum_cheeks"
|
||||
name="sebum_cheeks"
|
||||
label={m["skin_sebumCheeks"]()}
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
bind:value={sebumCheeks}
|
||||
/>
|
||||
<LabeledInputField
|
||||
id="active_concerns"
|
||||
name="active_concerns"
|
||||
label={m["skin_activeConcerns"]()}
|
||||
placeholder={m["skin_activeConcernsPlaceholder"]()}
|
||||
className="space-y-1 col-span-2"
|
||||
bind:value={activeConcernsRaw}
|
||||
/>
|
||||
<LabeledInputField
|
||||
id="priorities"
|
||||
name="priorities"
|
||||
label={m["skin_priorities"]()}
|
||||
placeholder={m["skin_prioritiesPlaceholder"]()}
|
||||
className="space-y-1 col-span-2"
|
||||
bind:value={prioritiesRaw}
|
||||
/>
|
||||
<LabeledInputField
|
||||
id="notes"
|
||||
name="notes"
|
||||
label={m.skin_notes()}
|
||||
className="space-y-1 col-span-2"
|
||||
bind:value={notes}
|
||||
/>
|
||||
<div class="col-span-2">
|
||||
<Button type="submit">{m["skin_addSnapshot"]()}</Button>
|
||||
</div>
|
||||
|
|
@ -347,7 +363,7 @@
|
|||
</Card>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-4 reveal-2">
|
||||
{#each sortedSnapshots as snap (snap.id)}
|
||||
<Card>
|
||||
<CardContent class="pt-4">
|
||||
|
|
@ -363,86 +379,105 @@
|
|||
class="grid grid-cols-1 sm:grid-cols-2 gap-4"
|
||||
>
|
||||
<input type="hidden" name="id" value={snap.id} />
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_snapshot_date">{m.skin_date()}</Label>
|
||||
<Input id="edit_snapshot_date" name="snapshot_date" type="date" bind:value={editSnapshotDate} required />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label>{m["skin_overallState"]()}</Label>
|
||||
<input type="hidden" name="overall_state" value={editOverallState} />
|
||||
<Select type="single" value={editOverallState} onValueChange={(v) => (editOverallState = v)}>
|
||||
<SelectTrigger>{editOverallState ? stateLabels[editOverallState]?.() ?? editOverallState : m.common_select()}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each states as s (s)}
|
||||
<SelectItem value={s}>{stateLabels[s]?.() ?? s}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label>{m.skin_texture()}</Label>
|
||||
<input type="hidden" name="texture" value={editTexture} />
|
||||
<Select type="single" value={editTexture} onValueChange={(v) => (editTexture = v)}>
|
||||
<SelectTrigger>{editTexture ? textureLabels[editTexture]?.() ?? editTexture : m.common_select()}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each skinTextures as t (t)}
|
||||
<SelectItem value={t}>{textureLabels[t]?.() ?? t}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label>{m["skin_skinType"]()}</Label>
|
||||
<input type="hidden" name="skin_type" value={editSkinType} />
|
||||
<Select type="single" value={editSkinType} onValueChange={(v) => (editSkinType = v)}>
|
||||
<SelectTrigger>{editSkinType ? skinTypeLabels[editSkinType]?.() ?? editSkinType : m.common_select()}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each skinTypes as st (st)}
|
||||
<SelectItem value={st}>{skinTypeLabels[st]?.() ?? st}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label>{m["skin_barrierState"]()}</Label>
|
||||
<input type="hidden" name="barrier_state" value={editBarrierState} />
|
||||
<Select type="single" value={editBarrierState} onValueChange={(v) => (editBarrierState = v)}>
|
||||
<SelectTrigger>{editBarrierState ? barrierLabels[editBarrierState]?.() ?? editBarrierState : m.common_select()}</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each barrierStates as b (b)}
|
||||
<SelectItem value={b}>{barrierLabels[b]?.() ?? b}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_hydration_level">{m.skin_hydration()}</Label>
|
||||
<Input id="edit_hydration_level" name="hydration_level" type="number" min="1" max="5" bind:value={editHydrationLevel} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_sensitivity_level">{m.skin_sensitivity()}</Label>
|
||||
<Input id="edit_sensitivity_level" name="sensitivity_level" type="number" min="1" max="5" bind:value={editSensitivityLevel} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_sebum_tzone">{m["skin_sebumTzone"]()}</Label>
|
||||
<Input id="edit_sebum_tzone" name="sebum_tzone" type="number" min="1" max="5" bind:value={editSebumTzone} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label for="edit_sebum_cheeks">{m["skin_sebumCheeks"]()}</Label>
|
||||
<Input id="edit_sebum_cheeks" name="sebum_cheeks" type="number" min="1" max="5" bind:value={editSebumCheeks} />
|
||||
</div>
|
||||
<div class="space-y-1 col-span-2">
|
||||
<Label for="edit_active_concerns">{m["skin_activeConcerns"]()}</Label>
|
||||
<Input id="edit_active_concerns" name="active_concerns" placeholder={m["skin_activeConcernsPlaceholder"]()} bind:value={editActiveConcernsRaw} />
|
||||
</div>
|
||||
<div class="space-y-1 col-span-2">
|
||||
<Label for="edit_priorities">{m["skin_priorities"]()}</Label>
|
||||
<Input id="edit_priorities" name="priorities" placeholder={m["skin_prioritiesPlaceholder"]()} bind:value={editPrioritiesRaw} />
|
||||
</div>
|
||||
<div class="space-y-1 col-span-2">
|
||||
<Label for="edit_notes">{m.skin_notes()}</Label>
|
||||
<Input id="edit_notes" name="notes" bind:value={editNotes} />
|
||||
</div>
|
||||
<LabeledInputField
|
||||
id="edit_snapshot_date"
|
||||
name="snapshot_date"
|
||||
label={m.skin_date()}
|
||||
type="date"
|
||||
required
|
||||
bind:value={editSnapshotDate}
|
||||
/>
|
||||
<SimpleSelect
|
||||
id="edit_overall_state"
|
||||
name="overall_state"
|
||||
label={m["skin_overallState"]()}
|
||||
options={overallStateOptions}
|
||||
placeholder={m.common_select()}
|
||||
bind:value={editOverallState}
|
||||
/>
|
||||
<SimpleSelect
|
||||
id="edit_texture"
|
||||
name="texture"
|
||||
label={m.skin_texture()}
|
||||
options={textureOptions}
|
||||
placeholder={m.common_select()}
|
||||
bind:value={editTexture}
|
||||
/>
|
||||
<SimpleSelect
|
||||
id="edit_skin_type"
|
||||
name="skin_type"
|
||||
label={m["skin_skinType"]()}
|
||||
options={skinTypeOptions}
|
||||
placeholder={m.common_select()}
|
||||
bind:value={editSkinType}
|
||||
/>
|
||||
<SimpleSelect
|
||||
id="edit_barrier_state"
|
||||
name="barrier_state"
|
||||
label={m["skin_barrierState"]()}
|
||||
options={barrierOptions}
|
||||
placeholder={m.common_select()}
|
||||
bind:value={editBarrierState}
|
||||
/>
|
||||
<LabeledInputField
|
||||
id="edit_hydration_level"
|
||||
name="hydration_level"
|
||||
label={m.skin_hydration()}
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
bind:value={editHydrationLevel}
|
||||
/>
|
||||
<LabeledInputField
|
||||
id="edit_sensitivity_level"
|
||||
name="sensitivity_level"
|
||||
label={m.skin_sensitivity()}
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
bind:value={editSensitivityLevel}
|
||||
/>
|
||||
<LabeledInputField
|
||||
id="edit_sebum_tzone"
|
||||
name="sebum_tzone"
|
||||
label={m["skin_sebumTzone"]()}
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
bind:value={editSebumTzone}
|
||||
/>
|
||||
<LabeledInputField
|
||||
id="edit_sebum_cheeks"
|
||||
name="sebum_cheeks"
|
||||
label={m["skin_sebumCheeks"]()}
|
||||
type="number"
|
||||
min="1"
|
||||
max="5"
|
||||
bind:value={editSebumCheeks}
|
||||
/>
|
||||
<LabeledInputField
|
||||
id="edit_active_concerns"
|
||||
name="active_concerns"
|
||||
label={m["skin_activeConcerns"]()}
|
||||
placeholder={m["skin_activeConcernsPlaceholder"]()}
|
||||
className="space-y-1 col-span-2"
|
||||
bind:value={editActiveConcernsRaw}
|
||||
/>
|
||||
<LabeledInputField
|
||||
id="edit_priorities"
|
||||
name="priorities"
|
||||
label={m["skin_priorities"]()}
|
||||
placeholder={m["skin_prioritiesPlaceholder"]()}
|
||||
className="space-y-1 col-span-2"
|
||||
bind:value={editPrioritiesRaw}
|
||||
/>
|
||||
<LabeledInputField
|
||||
id="edit_notes"
|
||||
name="notes"
|
||||
label={m.skin_notes()}
|
||||
className="space-y-1 col-span-2"
|
||||
bind:value={editNotes}
|
||||
/>
|
||||
<div class="col-span-2 flex gap-2">
|
||||
<Button type="submit">{m.common_save()}</Button>
|
||||
<Button type="button" variant="outline" onclick={() => (editingId = null)}>{m.common_cancel()}</Button>
|
||||
|
|
@ -463,10 +498,10 @@
|
|||
</div>
|
||||
{#if snap.overall_state || snap.texture}
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
{#if snap.overall_state}
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {stateColors[snap.overall_state] ?? ''}">
|
||||
{stateLabels[snap.overall_state]?.() ?? snap.overall_state}
|
||||
</span>
|
||||
{#if snap.overall_state}
|
||||
<span class={statePills[snap.overall_state] ?? 'state-pill'}>
|
||||
{stateLabels[snap.overall_state]?.() ?? snap.overall_state}
|
||||
</span>
|
||||
{/if}
|
||||
{#if snap.texture}
|
||||
<Badge variant="secondary">{textureLabels[snap.texture]?.() ?? snap.texture}</Badge>
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import type { Plugin, Rollup } from 'vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { paraglideVitePlugin } from '@inlang/paraglide-js';
|
||||
|
||||
const stripDeprecatedRollupOptions = {
|
||||
const stripDeprecatedRollupOptions: Plugin = {
|
||||
name: 'strip-deprecated-rollup-options',
|
||||
outputOptions(options: Record<string, unknown>) {
|
||||
if ('codeSplitting' in options) {
|
||||
delete options.codeSplitting;
|
||||
outputOptions(options: Rollup.OutputOptions) {
|
||||
const nextOptions = { ...options } as Rollup.OutputOptions & { codeSplitting?: unknown };
|
||||
if ('codeSplitting' in nextOptions) {
|
||||
delete nextOptions.codeSplitting;
|
||||
}
|
||||
return options;
|
||||
return nextOptions;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue