diff --git a/app.config.ts b/app.config.ts index 3712cf2..ddde460 100644 --- a/app.config.ts +++ b/app.config.ts @@ -89,7 +89,7 @@ export default defineConfig({ ? `/${process.env.GITHUB_REPOSITORY.split("/")[1]}/` : "/", prerender: { - routes: ["/cookmark", "/cookmark/en", "/cookmark/sk"], + routes: ["/cookmark"], }, }, }); diff --git a/src/app.tsx b/src/app.tsx index 7ca6545..b43997d 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -14,6 +14,16 @@ export default function App() { root={(props) => ( Cookmark + + + + {props.children} diff --git a/src/components/FilterDrawer/FilterDrawer.module.css b/src/components/FilterDrawer/FilterDrawer.module.css new file mode 100644 index 0000000..6283013 --- /dev/null +++ b/src/components/FilterDrawer/FilterDrawer.module.css @@ -0,0 +1,222 @@ +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 40; +} + +.overlay[data-transitioning] { + transition: opacity 250ms cubic-bezier(0.32, 0.72, 0, 1); + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); +} + +.content { + position: fixed; + top: 0; + left: 0; + width: 320px; + max-width: 90vw; + height: 100vh; + background: white; + box-shadow: var(--shadow-lg); + z-index: 50; + transform: translateX(-100%); + display: flex; + flex-direction: column; +} + +.content[data-transitioning] { + transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1); +} + +.content[data-opening] { + transform: translateX(0); +} + +.content[data-closing] { + transform: translateX(-100%); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-6); + border-bottom: 1px solid var(--color-neutral-200); +} + +.title { + font-family: var(--font-family-display); + font-size: var(--text-xl); + font-weight: 600; + color: var(--color-neutral-900); + margin: 0; +} + +.closeButton { + display: flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + background: transparent; + border: none; + border-radius: var(--radius-full); + cursor: pointer; + color: var(--color-neutral-600); + transition: all 0.15s ease-in-out; +} + +.closeButton:hover { + background: var(--color-neutral-100); + color: var(--color-neutral-900); +} + +.closeButton:focus { + outline: var(--focus-ring-width) solid var(--focus-ring-color); + outline-offset: var(--focus-ring-offset); +} + +.sections { + flex: 1; + overflow-y: auto; + padding: var(--space-6); +} + +.section { + margin-bottom: var(--space-6); +} + +.section:last-child { + margin-bottom: 0; +} + +.sectionTitle { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-neutral-400); + margin: 0 0 var(--space-2) 0; +} + +.checkboxGroup { + display: flex; + flex-direction: column; + gap: 0; +} + +.checkboxLabel { + display: flex; + align-items: center; + gap: var(--space-3); + cursor: pointer; + padding: var(--space-2) var(--space-2); + margin: 0 calc(-1 * var(--space-2)); + border-radius: var(--radius-md); + transition: background-color 0.15s; +} + +.checkboxLabel:hover { + background-color: var(--color-neutral-50); +} + +.checkbox { + appearance: none; + -webkit-appearance: none; + width: 18px; + height: 18px; + border: 1.5px solid var(--color-neutral-300); + border-radius: 4px; + cursor: pointer; + position: relative; + flex-shrink: 0; + transition: all 0.15s ease; + background-color: white; +} + +.checkbox:hover { + border-color: var(--color-neutral-400); +} + +.checkbox:checked { + background-color: var(--color-neutral-900); + border-color: var(--color-neutral-900); +} + +.checkbox:checked::after { + content: ""; + position: absolute; + left: 5px; + top: 2px; + width: 5px; + height: 9px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.checkbox:focus { + outline: none; + box-shadow: 0 0 0 2px var(--color-neutral-200); +} + +.checkboxText { + font-size: var(--text-sm); + color: var(--color-neutral-700); + font-weight: 400; + line-height: 1.4; +} + +.checkboxLabel:hover .checkboxText { + color: var(--color-neutral-900); +} + +.footer { + padding: var(--space-6); + border-top: 1px solid var(--color-neutral-200); +} + +.clearButton { + width: 100%; + padding: var(--space-3) var(--space-4); + background: transparent; + border: 1px solid var(--color-neutral-300); + border-radius: var(--radius-lg); + font-size: var(--text-sm); + font-weight: 500; + color: var(--color-neutral-600); + cursor: pointer; + transition: all 0.15s ease-in-out; +} + +.clearButton:hover { + background: var(--color-neutral-100); + border-color: var(--color-neutral-400); + color: var(--color-neutral-800); +} + +.clearButton:focus { + outline: var(--focus-ring-width) solid var(--focus-ring-color); + outline-offset: var(--focus-ring-offset); +} + +@media (max-width: 480px) { + .content { + width: 100vw; + max-width: 100vw; + } + + .header { + padding: var(--space-4); + } + + .sections { + padding: var(--space-4); + } + + .footer { + padding: var(--space-4); + } +} diff --git a/src/components/FilterDrawer/FilterDrawer.tsx b/src/components/FilterDrawer/FilterDrawer.tsx new file mode 100644 index 0000000..85901d0 --- /dev/null +++ b/src/components/FilterDrawer/FilterDrawer.tsx @@ -0,0 +1,141 @@ +import Drawer from "@corvu/drawer"; +import { type Component, For } from "solid-js"; +import type { DifficultyFilter, DifficultyValue } from "~/constants/difficultyOptions"; +import { difficultyOptions, tagOptions, timeOptions } from "~/constants/filterOptions"; +import { strings } from "~/constants/strings"; +import type { TagFilter, TagValue } from "~/constants/tagOptions"; +import type { TimeFilter, TimeValue } from "~/constants/timeOptions"; +import styles from "./FilterDrawer.module.css"; + +type FilterDrawerProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + difficultyFilter: DifficultyFilter; + timeFilter: TimeFilter; + tagFilter: TagFilter; + onDifficultyChange: (difficulty: DifficultyFilter) => void; + onTimeChange: (time: TimeFilter) => void; + onTagChange: (tag: TagFilter) => void; + onClearAll: () => void; +}; + +const FilterDrawer: Component = (props) => { + const handleDifficultyToggle = (value: DifficultyValue) => { + const current = props.difficultyFilter; + const newFilter = current.includes(value) + ? current.filter((v) => v !== value) + : [...current, value]; + props.onDifficultyChange(newFilter as DifficultyFilter); + }; + + const handleTimeToggle = (value: TimeValue) => { + const current = props.timeFilter; + const newFilter = current.includes(value) + ? current.filter((v) => v !== value) + : [...current, value]; + props.onTimeChange(newFilter as TimeFilter); + }; + + const handleTagToggle = (value: TagValue) => { + const current = props.tagFilter; + const newFilter = current.includes(value) + ? current.filter((v) => v !== value) + : [...current, value]; + props.onTagChange(newFilter as TagFilter); + }; + + const hasAnyFilter = () => + props.difficultyFilter.length > 0 || props.timeFilter.length > 0 || props.tagFilter.length > 0; + + return ( + + + + + ); +}; + +export default FilterDrawer; diff --git a/src/components/FilterSidebar/FilterSidebar.module.css b/src/components/FilterSidebar/FilterSidebar.module.css deleted file mode 100644 index cb222ad..0000000 --- a/src/components/FilterSidebar/FilterSidebar.module.css +++ /dev/null @@ -1,101 +0,0 @@ -.sidebar { - width: 280px; - padding: var(--space-6); - background-color: var(--color-neutral-50); - height: 100%; - position: relative; -} - -.filterSection { - margin-bottom: var(--space-6); -} - -.filterTitle { - font-size: var(--text-lg); - font-weight: 600; - color: var(--color-neutral-900); - margin: 0 0 var(--space-4) 0; -} - -.filterOptions { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); -} - -.filterOption { - display: flex; - align-items: center; - gap: var(--space-2); - cursor: pointer; - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-full); - background-color: var(--color-neutral-100); - border: 2px solid transparent; - transition: all 0.2s ease; - font-size: var(--text-sm); - white-space: nowrap; -} - -.filterOption:hover { - background-color: var(--color-neutral-200); - transform: translateY(-1px); -} - -.filterInput { - margin: 0; - cursor: pointer; - width: 16px; - height: 16px; -} - -.filterLabel { - color: var(--color-neutral-700); - font-size: var(--text-sm); - font-weight: 500; - cursor: pointer; - user-select: none; -} - -.filterOption:has(.filterInput:checked) { - background-color: var(--color-primary-100); - border-color: var(--color-primary-300); -} - -.filterOption:has(.filterInput:checked) .filterLabel { - color: var(--color-primary-700); - font-weight: 600; -} - -/* Mobile responsive */ -@media (max-width: 768px) { - .sidebar { - width: 100%; - height: auto; - padding: var(--space-4); - border-right: none; - border-bottom: 1px solid var(--color-neutral-200); - background-color: var(--color-neutral-50); - } - - .filterTitle { - font-size: var(--text-base); - margin-bottom: var(--space-3); - } - - .filterOptions { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); - } - - .filterOption { - font-size: var(--text-xs); - padding: var(--space-1) var(--space-2); - } - - .filterInput { - width: 14px; - height: 14px; - } -} diff --git a/src/components/FilterSidebar/FilterSidebar.test.tsx b/src/components/FilterSidebar/FilterSidebar.test.tsx deleted file mode 100644 index 9ee0c33..0000000 --- a/src/components/FilterSidebar/FilterSidebar.test.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { fireEvent, render } from "@solidjs/testing-library"; -import { describe, expect, it, vi } from "vitest"; -import FilterSidebar from "./FilterSidebar.jsx"; - -describe("", () => { - it("renders all filter sections", () => { - const mockOnFilterChange = vi.fn(); - const mockOnTimeFilterChange = vi.fn(); - const mockOnTagFilterChange = vi.fn(); - const { getByText } = render(() => ( - - )); - - expect(getByText("Difficulty")).toBeInTheDocument(); - expect(getByText("Time")).toBeInTheDocument(); - }); - - it("renders all difficulty options", () => { - const mockOnFilterChange = vi.fn(); - const mockOnTimeFilterChange = vi.fn(); - const mockOnTagFilterChange = vi.fn(); - const { getAllByText, getByText } = render(() => ( - - )); - - const allOptions = getAllByText("All"); - expect(allOptions).toHaveLength(3); // One for difficulty, time, and tags - expect(getByText("Easy")).toBeInTheDocument(); - expect(getByText("Medium")).toBeInTheDocument(); - expect(getByText("Hard")).toBeInTheDocument(); - }); - - it("calls onFilterChange when difficulty filter changes", () => { - const mockOnFilterChange = vi.fn(); - const mockOnTimeFilterChange = vi.fn(); - const mockOnTagFilterChange = vi.fn(); - const { getByLabelText } = render(() => ( - - )); - - const easyRadio = getByLabelText("Easy"); - fireEvent.click(easyRadio); - - expect(mockOnFilterChange).toHaveBeenCalledWith("Easy"); - }); - - it("renders all time options", () => { - const mockOnFilterChange = vi.fn(); - const mockOnTimeFilterChange = vi.fn(); - const mockOnTagFilterChange = vi.fn(); - const { getByText } = render(() => ( - - )); - - expect(getByText("Under 30 min")).toBeInTheDocument(); - expect(getByText("Under 1 hour")).toBeInTheDocument(); - expect(getByText("1+ hours")).toBeInTheDocument(); - }); -}); diff --git a/src/components/FilterSidebar/FilterSidebar.tsx b/src/components/FilterSidebar/FilterSidebar.tsx deleted file mode 100644 index a30bc85..0000000 --- a/src/components/FilterSidebar/FilterSidebar.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { For } from "solid-js"; -import type { DifficultyFilter } from "~/constants/difficultyOptions"; -import { - createDifficultyOptions, - createTagOptions, - createTimeOptions, -} from "~/constants/filterOptions"; -import type { TagFilter } from "~/constants/tagOptions"; -import type { TimeFilter } from "~/constants/timeOptions"; -import { useT } from "~/lib/i18nContext"; -import styles from "./FilterSidebar.module.css"; - -type FilterSidebarProps = { - difficultyFilter: DifficultyFilter; - timeFilter: TimeFilter; - tagFilter: TagFilter; - onFilterChange: (difficulty: DifficultyFilter) => void; - onTimeFilterChange: (timeFilter: TimeFilter) => void; - onTagFilterChange: (tag: TagFilter) => void; -}; - -export default function FilterSidebar(props: FilterSidebarProps) { - const t = useT(); - - const difficultyOptions = createDifficultyOptions(t); - const timeOptions = createTimeOptions(t); - const tagOptions = createTagOptions(t); - - const handleDifficultyChange = (difficulty: DifficultyFilter) => { - props.onFilterChange(difficulty); - }; - - const handleTimeFilterChange = (timeFilter: TimeFilter) => { - props.onTimeFilterChange(timeFilter); - }; - - const handleTagChange = (tag: TagFilter) => { - props.onTagFilterChange(tag); - }; - - return ( - - ); -} diff --git a/src/components/LanguageSwitcher/LanguageSwitcher.module.css b/src/components/LanguageSwitcher/LanguageSwitcher.module.css deleted file mode 100644 index e9b35d6..0000000 --- a/src/components/LanguageSwitcher/LanguageSwitcher.module.css +++ /dev/null @@ -1,66 +0,0 @@ -.switcher { - display: flex; - align-items: center; - gap: var(--space-2); - padding: var(--space-2) var(--space-3); - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); - border-radius: var(--radius-md); - color: var(--color-neutral-900); - font-family: var(--font-family-base); - font-size: var(--text-sm); - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - backdrop-filter: blur(8px); -} - -.switcher:hover { - background: rgba(255, 255, 255, 0.2); - border-color: rgba(255, 255, 255, 0.3); - transform: translateY(-1px); -} - -.switcher:focus { - outline: var(--focus-ring-width) solid var(--focus-ring-color); - outline-offset: var(--focus-ring-offset); -} - -.switcher:active { - transform: translateY(0); - background: rgba(255, 255, 255, 0.15); -} - -.icon { - font-size: var(--text-md); - line-height: 1; -} - -.language { - font-weight: 500; - letter-spacing: 0.025em; -} - -/* Mobile responsive */ -@media (max-width: 768px) { - .language { - display: none; - } - - .switcher { - padding: var(--space-2); - min-width: 36px; - justify-content: center; - } -} - -@media (max-width: 480px) { - .switcher { - min-width: 32px; - padding: var(--space-1) var(--space-2); - } - - .icon { - font-size: var(--text-sm); - } -} diff --git a/src/components/LanguageSwitcher/LanguageSwitcher.tsx b/src/components/LanguageSwitcher/LanguageSwitcher.tsx deleted file mode 100644 index aee73c1..0000000 --- a/src/components/LanguageSwitcher/LanguageSwitcher.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useParams } from "@solidjs/router"; -import { createMemo } from "solid-js"; -import type { Locale } from "~/i18n"; -import { useT } from "~/lib/i18nContext"; -import styles from "./LanguageSwitcher.module.css"; - -const LanguageSwitcher = () => { - const t = useT(); - const params = useParams<{ lang: Locale }>(); - - const currentLang = params.lang; - const otherLang = createMemo(() => (currentLang === "en" ? "sk" : "en")); - - const handleLanguageSwitch = () => { - window.location.replace(`/cookmark/${otherLang()}`); - }; - - const switchToLang = createMemo(() => (currentLang === "en" ? "Slovak" : "English")); - const switchToLabel = createMemo(() => - otherLang() === "en" ? t.languageSwitcher.switchToEnglish : t.languageSwitcher.switchToSlovak, - ); - - return ( - - ); -}; - -export default LanguageSwitcher; diff --git a/src/components/Pagination/Pagination.module.css b/src/components/Pagination/Pagination.module.css index 91e3834..ff820f4 100644 --- a/src/components/Pagination/Pagination.module.css +++ b/src/components/Pagination/Pagination.module.css @@ -13,10 +13,10 @@ align-items: center; gap: var(--space-2); padding: var(--space-2) var(--space-3); - background-color: var(--color-neutral-50); - border: 1px solid var(--color-neutral-300); + background-color: transparent; + border: none; border-radius: var(--radius-md); - color: var(--color-neutral-700); + color: var(--color-neutral-500); font-size: var(--text-sm); font-weight: 500; cursor: pointer; @@ -25,18 +25,16 @@ } .trigger:hover:not([data-disabled]) { - background-color: var(--color-primary-50); - border-color: var(--color-primary-300); - color: var(--color-primary-600); + background-color: var(--color-neutral-100); + color: var(--color-neutral-900); } .trigger:focus-visible { - box-shadow: 0 0 0 2px var(--color-primary-300); - border-color: var(--color-primary-400); + box-shadow: 0 0 0 2px var(--color-neutral-300); } .trigger[data-disabled] { - opacity: 0.5; + opacity: 0.4; cursor: not-allowed; } @@ -58,9 +56,9 @@ justify-content: center; padding: 0 var(--space-2); background-color: transparent; - border: 1px solid transparent; + border: none; border-radius: var(--radius-md); - color: var(--color-neutral-600); + color: var(--color-neutral-500); font-size: var(--text-sm); font-weight: 500; cursor: pointer; @@ -70,18 +68,15 @@ .pageItem:hover:not([data-selected]) { background-color: var(--color-neutral-100); - border-color: var(--color-neutral-300); - color: var(--color-neutral-800); + color: var(--color-neutral-900); } .pageItem:focus-visible { - box-shadow: 0 0 0 2px var(--color-primary-300); - border-color: var(--color-primary-400); + box-shadow: 0 0 0 2px var(--color-neutral-300); } .pageItem[data-selected] { - background-color: var(--color-primary-500); - border-color: var(--color-primary-500); + background-color: var(--color-neutral-900); color: white; cursor: default; } diff --git a/src/components/Pagination/Pagination.test.tsx b/src/components/Pagination/Pagination.test.tsx index aae2063..702d82a 100644 --- a/src/components/Pagination/Pagination.test.tsx +++ b/src/components/Pagination/Pagination.test.tsx @@ -1,44 +1,26 @@ import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; -import type { JSX } from "solid-js"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { I18nProvider } from "~/lib/i18nContext"; import { Pagination } from "./Pagination.jsx"; -const TestWrapper = (props: { children: JSX.Element }) => ( - {props.children} -); - describe("Pagination", () => { afterEach(() => { cleanup(); }); it("should not render when count is less than or equal to page size", () => { - const { container } = render(() => ( - - - - )); + const { container } = render(() => ); expect(container.firstChild).toBeNull(); }); it("should render pagination when count exceeds page size", () => { - render(() => ( - - - - )); + render(() => ); expect(screen.getByLabelText("Previous")).toBeInTheDocument(); expect(screen.getByLabelText("Next")).toBeInTheDocument(); expect(screen.getByLabelText("Go to page 1")).toBeInTheDocument(); }); it("should display correct number of pages", () => { - render(() => ( - - - - )); + render(() => ); expect(screen.getByLabelText("Go to page 1")).toBeInTheDocument(); expect(screen.getByLabelText("Go to page 2")).toBeInTheDocument(); expect(screen.getByLabelText("Go to page 3")).toBeInTheDocument(); @@ -49,11 +31,7 @@ describe("Pagination", () => { it("should call onPageChange when page is clicked", () => { const handlePageChange = vi.fn(); - render(() => ( - - - - )); + render(() => ); const page2Button = screen.getByLabelText("Go to page 2"); fireEvent.click(page2Button); @@ -63,11 +41,7 @@ describe("Pagination", () => { it("should call onPageChange when next is clicked", () => { const handlePageChange = vi.fn(); - render(() => ( - - - - )); + render(() => ); const nextButton = screen.getByLabelText("Next"); fireEvent.click(nextButton); @@ -77,11 +51,7 @@ describe("Pagination", () => { it("should call onPageChange when previous is clicked", () => { const handlePageChange = vi.fn(); - render(() => ( - - - - )); + render(() => ); const prevButton = screen.getByLabelText("Previous"); fireEvent.click(prevButton); @@ -90,31 +60,19 @@ describe("Pagination", () => { }); it("should display ellipsis for many pages", () => { - render(() => ( - - - - )); + render(() => ); const ellipsis = screen.getAllByText("⋯"); expect(ellipsis.length).toBeGreaterThan(0); }); it("should apply custom class", () => { - const { container } = render(() => ( - - - - )); + const { container } = render(() => ); const pagination = container.querySelector(".custom-class"); expect(pagination).toBeInTheDocument(); }); it("should highlight current page", () => { - render(() => ( - - - - )); + render(() => ); const page2Button = screen.getByLabelText("Go to page 2"); expect(page2Button).toHaveAttribute("data-selected"); }); diff --git a/src/components/Pagination/Pagination.tsx b/src/components/Pagination/Pagination.tsx index da91327..5bb9930 100644 --- a/src/components/Pagination/Pagination.tsx +++ b/src/components/Pagination/Pagination.tsx @@ -1,7 +1,7 @@ import { Pagination as ArkPagination } from "@ark-ui/solid/pagination"; import type { Component } from "solid-js"; import { For, Show } from "solid-js"; -import { useT } from "~/lib/i18nContext"; +import { strings } from "~/constants/strings"; import styles from "./Pagination.module.css"; type PaginationProps = { @@ -13,8 +13,6 @@ type PaginationProps = { }; export const Pagination: Component = (props) => { - const t = useT(); - return ( props.pageSize}> = (props) => { siblingCount={1} class={`${styles.pagination} ${props.class || ""}`} > - + = (props) => { stroke-linejoin="round" /> - {t.pagination.previous} + {strings.pagination.previous} @@ -54,7 +52,7 @@ export const Pagination: Component = (props) => { {page.value} @@ -69,8 +67,8 @@ export const Pagination: Component = (props) => { )} - - {t.pagination.next} + + {strings.pagination.next} ", () => { { name: "Pasta", amount: "500", unit: "g" }, { name: "Tomato sauce", amount: "400", unit: "ml" }, ], - instructions: ["Boil water", "Cook pasta"], + instructions: [{ name: "Príprava", steps: ["Boil water", "Cook pasta"] }], source_url: null, }; + const mockRecipeWithMultipleSections: RecipeData = { + ...mockRecipe, + title: "Multi-Section Recipe", + instructions: [ + { name: "Marinade", steps: ["Mix spices", "Coat meat"] }, + { name: "Cooking", steps: ["Heat pan", "Cook until done"] }, + ], + }; + it("renders recipe with all details", () => { const { getByText } = render(() => ); @@ -62,4 +71,26 @@ describe("", () => { expect(getByText("pasta")).toBeInTheDocument(); expect(getByText("quick")).toBeInTheDocument(); }); + + it("hides section title when only one instruction section", () => { + const { queryByText } = render(() => ); + + expect(queryByText("Príprava")).not.toBeInTheDocument(); + }); + + it("shows section titles when multiple instruction sections", () => { + const { getByText } = render(() => ); + + expect(getByText("Marinade")).toBeInTheDocument(); + expect(getByText("Cooking")).toBeInTheDocument(); + }); + + it("renders steps from multiple sections", () => { + const { getByText } = render(() => ); + + expect(getByText("Mix spices")).toBeInTheDocument(); + expect(getByText("Coat meat")).toBeInTheDocument(); + expect(getByText("Heat pan")).toBeInTheDocument(); + expect(getByText("Cook until done")).toBeInTheDocument(); + }); }); diff --git a/src/components/RecipeDetail/RecipeDetail.tsx b/src/components/RecipeDetail/RecipeDetail.tsx index b40f8f2..5eae541 100644 --- a/src/components/RecipeDetail/RecipeDetail.tsx +++ b/src/components/RecipeDetail/RecipeDetail.tsx @@ -1,5 +1,5 @@ -import type { Component } from "solid-js"; -import { useT } from "~/lib/i18nContext"; +import { type Component, For, Show } from "solid-js"; +import { strings } from "~/constants/strings"; import type { RecipeData } from "~/types/Recipe"; import styles from "./RecipeDetail.module.css"; @@ -8,7 +8,6 @@ type RecipeDetailProps = { }; const RecipeDetail: Component = (props) => { - const t = useT(); const formatTime = (time: number | null): string => { return time ? `${time} min` : "N/A"; }; @@ -17,8 +16,6 @@ const RecipeDetail: Component = (props) => { return servings ? `${servings} servings` : "N/A"; }; - console.log(props.recipe); - return (
@@ -37,26 +34,26 @@ const RecipeDetail: Component = (props) => {
{props.recipe.prep_time && ( - {t.recipe.prep} {formatTime(props.recipe.prep_time)} + {strings.recipe.prep} {formatTime(props.recipe.prep_time)} )} {props.recipe.cook_time && ( - {t.recipe.cook} {formatTime(props.recipe.cook_time)} + {strings.recipe.cook} {formatTime(props.recipe.cook_time)} )} - {t.recipe.total} {formatTime(props.recipe.total_time)} + {strings.recipe.total} {formatTime(props.recipe.total_time)} - {t.recipe.servings} {formatServings(props.recipe.servings)} + {strings.recipe.servings} {formatServings(props.recipe.servings)}
{props.recipe.cuisine && (
- {t.recipe.cuisine} {props.recipe.cuisine} + {strings.recipe.cuisine} {props.recipe.cuisine}
)} @@ -83,18 +80,18 @@ const RecipeDetail: Component = (props) => { aria-label="External link" > External link - - - + + + - {t.recipe.viewSource} + {strings.recipe.viewSource} )}
-

{t.recipe.ingredients}

+

{strings.recipe.ingredients}

    {props.recipe.ingredients.map((ingredient) => (
  • @@ -109,15 +106,24 @@ const RecipeDetail: Component = (props) => {
-

{t.recipe.instructions}

-
    - {props.recipe.instructions.map((instruction, index) => ( -
  1. - {index + 1} - {instruction} -
  2. - ))} -
+

{strings.recipe.instructions}

+ + {(section) => ( +
+ 1}> +

{section.name}

+
+
    + {section.steps.map((step, index) => ( +
  1. + {index + 1} + {step} +
  2. + ))} +
+
+ )} +
); diff --git a/src/components/RecipeDrawer/RecipeDrawer.module.css b/src/components/RecipeDrawer/RecipeDrawer.module.css deleted file mode 100644 index da72d94..0000000 --- a/src/components/RecipeDrawer/RecipeDrawer.module.css +++ /dev/null @@ -1,124 +0,0 @@ -/* Drawer overlay */ -.overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.5); - z-index: 40; -} - -.overlay[data-transitioning] { - transition: opacity 250ms cubic-bezier(0.32, 0.72, 0, 1); - backdrop-filter: blur(2px); - -webkit-backdrop-filter: blur(2px); -} - -/* Drawer content */ -.content { - position: fixed; - top: 0; - right: 0; - width: 40vw; - min-width: 400px; - max-width: 600px; - height: 100vh; - background: white; - box-shadow: var(--shadow-lg); - z-index: 50; - transform: translateX(100%); - overflow: hidden; -} - -.content[data-transitioning] { - transition: transform 300ms cubic-bezier(0.32, 0.72, 0, 1); -} - -.content[data-opening] { - transform: translateX(0); -} - -.content[data-closing] { - transform: translateX(100%); -} - -/* Close button */ -.closeButton { - position: absolute; - top: var(--space-6); - right: var(--space-6); - width: 2.5rem; - height: 2.5rem; - text-align: center; - background: var(--color-neutral-200); - border: none; - border-radius: var(--radius-full); - cursor: pointer; - font-size: var(--text-xl); - font-weight: 300; - color: var(--color-neutral-700); - z-index: 10; - transition: all 0.15s ease-in-out; -} - -.closeButton:hover { - background: var(--color-neutral-300); - color: var(--color-neutral-900); - transform: scale(1.05); -} - -.closeButton:focus { - outline: var(--focus-ring-width) solid var(--focus-ring-color); - outline-offset: var(--focus-ring-offset); -} - -/* Responsive design */ -@media (max-width: 1024px) { - .content { - width: 40vw; - min-width: 350px; - max-width: 500px; - } -} - -@media (max-width: 768px) { - .content { - top: auto; - right: auto; - bottom: 0; - left: 0; - width: 100vw; - height: 80vh; - max-height: 90vh; - transform: translateY(100%); - border-radius: var(--radius-lg) var(--radius-lg) 0 0; - } - - .content[data-opening] { - transform: translateY(0); - } - - .content[data-closing] { - transform: translateY(100%); - } - - .closeButton { - top: var(--space-4); - right: var(--space-4); - background: var(--color-neutral-100); - border: 1px solid var(--color-neutral-300); - } -} - -@media (max-width: 480px) { - .content { - height: 85vh; - max-height: 95vh; - } - - .closeButton { - top: var(--space-3); - right: var(--space-3); - width: 2rem; - height: 2rem; - font-size: var(--text-lg); - } -} diff --git a/src/components/RecipeDrawer/RecipeDrawer.tsx b/src/components/RecipeDrawer/RecipeDrawer.tsx deleted file mode 100644 index 36659fe..0000000 --- a/src/components/RecipeDrawer/RecipeDrawer.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import Drawer from "@corvu/drawer"; -import { type Component, createMemo } from "solid-js"; -import RecipeDetail from "~/components/RecipeDetail/RecipeDetail"; -import type { RecipeData } from "~/types/Recipe"; -import createMediaQuery from "~/utils/createMediaQuery"; -import { getRecipeDataById } from "~/utils/loadRecipes"; -import styles from "./RecipeDrawer.module.css"; - -type RecipeDrawerProps = { - open: boolean; - onOpenChange: (open: boolean) => void; - recipeId: string | null; - onClose: () => void; -}; - -const RecipeDrawer: Component = (props) => { - const isMobile = createMediaQuery("(max-width: 768px)"); - - const selectedRecipeData = createMemo((): RecipeData | undefined => { - return props.recipeId ? getRecipeDataById(props.recipeId) : undefined; - }); - - return ( - - - - - ); -}; - -export default RecipeDrawer; diff --git a/src/components/RecipeList/RecipeList.module.css b/src/components/RecipeList/RecipeList.module.css index 1dd5df2..8ee7038 100644 --- a/src/components/RecipeList/RecipeList.module.css +++ b/src/components/RecipeList/RecipeList.module.css @@ -4,9 +4,41 @@ width: 100%; } +.header { + display: grid; + grid-template-columns: 1fr 120px 100px; + gap: var(--space-4); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-neutral-200); +} + +.headerName, +.headerDifficulty, +.headerTime { + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--color-neutral-500); +} + +.headerDifficulty { + text-align: center; +} + +.headerTime { + text-align: right; +} + .container { display: flex; flex-direction: column; width: 100%; min-height: 400px; } + +@media (max-width: 768px) { + .header { + display: none; + } +} diff --git a/src/components/RecipeList/RecipeList.test.tsx b/src/components/RecipeList/RecipeList.test.tsx index 6e8c619..aa2ea3b 100644 --- a/src/components/RecipeList/RecipeList.test.tsx +++ b/src/components/RecipeList/RecipeList.test.tsx @@ -1,13 +1,17 @@ import { cleanup, fireEvent, render, screen } from "@solidjs/testing-library"; import type { JSX } from "solid-js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { I18nProvider } from "~/lib/i18nContext"; import type { Recipe } from "~/types/Recipe"; import RecipeList from "./RecipeList.jsx"; -const TestWrapper = (props: { children: JSX.Element }) => ( - {props.children} -); +vi.mock("@solidjs/router", () => ({ + A: (props: { href: string; class: string; children: JSX.Element }) => ( + + {props.children} + + ), + useParams: () => ({}), +})); describe("", () => { beforeEach(() => { @@ -35,19 +39,14 @@ describe("", () => { tags: ["test"], })); - const mockOnRecipeSelect = vi.fn(); const { getByText, queryByText } = render(() => ( - - - + )); - // First 10 recipes should be visible on page 1 expect(getByText("Recipe 1")).toBeInTheDocument(); expect(getByText("Recipe 5")).toBeInTheDocument(); expect(getByText("Recipe 10")).toBeInTheDocument(); - // Recipe 11+ should not be visible on page 1 expect(queryByText("Recipe 11")).not.toBeInTheDocument(); expect(queryByText("Recipe 15")).not.toBeInTheDocument(); }); @@ -63,23 +62,14 @@ describe("", () => { tags: ["test"], })); - const mockOnRecipeSelect = vi.fn(); const mockOnPageChange = vi.fn(); render(() => ( - - - + )); const nextButton = screen.getByLabelText("Next"); fireEvent.click(nextButton); - // Verify onPageChange was called with page 2 expect(mockOnPageChange).toHaveBeenCalledWith(expect.objectContaining({ page: 2 })); }); @@ -94,17 +84,9 @@ describe("", () => { tags: ["test"], })); - const mockOnRecipeSelect = vi.fn(); const mockOnPageChange = vi.fn(); render(() => ( - - - + )); expect(screen.queryByText("Recipe 1")).not.toBeInTheDocument(); @@ -123,12 +105,7 @@ describe("", () => { tags: ["test"], })); - const mockOnRecipeSelect = vi.fn(); - render(() => ( - - - - )); + render(() => ); expect(screen.getByText("Recipe 1")).toBeInTheDocument(); expect(screen.getByText("Recipe 10")).toBeInTheDocument(); @@ -146,17 +123,9 @@ describe("", () => { tags: ["test"], })); - const mockOnRecipeSelect = vi.fn(); const mockOnPageChange = vi.fn(); const { unmount } = render(() => ( - - - + )); expect(screen.getByText("Recipe 1")).toBeInTheDocument(); @@ -176,14 +145,7 @@ describe("", () => { })); render(() => ( - - - + )); expect(screen.getByText("New Recipe 10")).toBeInTheDocument(); @@ -192,12 +154,7 @@ describe("", () => { }); it("renders empty list when no recipes provided", () => { - const mockOnRecipeSelect = vi.fn(); - const { container } = render(() => ( - - - - )); + const { container } = render(() => ); const wrapper = container.querySelector("div"); expect(wrapper).toBeTruthy(); @@ -216,17 +173,9 @@ describe("", () => { tags: ["test"], })); - const mockOnRecipeSelect = vi.fn(); const mockOnPageChange = vi.fn(); render(() => ( - - - + )); const nextButton = screen.getByLabelText("Next"); diff --git a/src/components/RecipeList/RecipeList.tsx b/src/components/RecipeList/RecipeList.tsx index 5c1e211..1dcb13b 100644 --- a/src/components/RecipeList/RecipeList.tsx +++ b/src/components/RecipeList/RecipeList.tsx @@ -1,4 +1,5 @@ -import { type Component, createMemo, For } from "solid-js"; +import { type Component, createMemo, For, Show } from "solid-js"; +import { strings } from "~/constants/strings"; import type { Recipe } from "~/types/Recipe"; import { Pagination } from "../Pagination/Pagination.jsx"; import styles from "./RecipeList.module.css"; @@ -6,7 +7,6 @@ import RecipeListItem from "./RecipeListItem.jsx"; type RecipeListProps = { recipes: ReadonlyArray; - onRecipeSelect: (id: string) => void; currentPage: number; onPageChange: (details: { page: number }) => void; }; @@ -28,15 +28,21 @@ const RecipeList: Component = (props) => { return (
+ 0}> +
+ {strings.recipeList.name} + {strings.recipeList.difficulty} + {strings.recipeList.time} +
+
{(recipe) => ( )} diff --git a/src/components/RecipeList/RecipeListItem.module.css b/src/components/RecipeList/RecipeListItem.module.css index a42f68e..ef549bb 100644 --- a/src/components/RecipeList/RecipeListItem.module.css +++ b/src/components/RecipeList/RecipeListItem.module.css @@ -1,19 +1,19 @@ .listItem { - display: flex; + display: grid; + grid-template-columns: 1fr 120px 100px; + gap: var(--space-4); align-items: center; width: 100%; - justify-content: space-between; - padding: var(--space-6) var(--space-8); - border-bottom: var(--border-width) solid var(--border-color); - transition: - background-color 0.15s ease-in-out, - padding 0.15s ease-in-out; + padding: var(--space-4); + border-bottom: 1px solid var(--color-neutral-100); + transition: background-color 0.15s ease-in-out; cursor: pointer; + text-decoration: none; + color: inherit; } .listItem:hover { - background-color: var(--color-neutral-100); - padding-left: var(--space-10); + background-color: var(--color-neutral-50); } .listItem:last-child { @@ -21,42 +21,72 @@ } .title { + font-family: var(--font-family-display); font-size: var(--text-lg); color: var(--color-neutral-900); - font-weight: 500; - flex: 1; + font-weight: 400; margin: 0; } .difficulty { - font-size: var(--text-sm); + font-size: var(--text-xs); font-weight: 500; - margin-right: var(--space-8); padding: var(--space-1) var(--space-3); border-radius: var(--radius-full); text-transform: uppercase; - letter-spacing: 0.025em; + letter-spacing: 0.05em; + text-align: center; + justify-self: center; } .difficulty[data-level="Easy"] { - background-color: var(--color-primary-200); - color: var(--color-primary-800); + background-color: var(--color-primary-100); + color: var(--color-primary-700); } .difficulty[data-level="Medium"] { - background-color: #fef3cd; - color: #856404; + background-color: #fef3c7; + color: #92400e; } .difficulty[data-level="Hard"] { - background-color: #f8d7da; - color: #721c24; + background-color: #fee2e2; + color: #991b1b; +} + +.difficulty[data-level="Unknown"] { + background-color: var(--color-neutral-100); + color: var(--color-neutral-600); } .time { - font-size: var(--text-md); - color: var(--color-neutral-700); - font-weight: 500; - min-width: 80px; + font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Mono", monospace; + font-size: var(--text-sm); + color: var(--color-neutral-600); + font-weight: 400; text-align: right; } + +@media (max-width: 768px) { + .listItem { + grid-template-columns: 1fr auto; + grid-template-rows: auto auto; + gap: var(--space-2); + padding: var(--space-4) var(--space-3); + } + + .title { + grid-column: 1 / -1; + font-size: var(--text-md); + } + + .difficulty { + justify-self: start; + font-size: 10px; + padding: var(--space-1) var(--space-2); + } + + .time { + font-size: var(--text-xs); + } +} diff --git a/src/components/RecipeList/RecipeListItem.test.tsx b/src/components/RecipeList/RecipeListItem.test.tsx index ee26f44..ac2cd40 100644 --- a/src/components/RecipeList/RecipeListItem.test.tsx +++ b/src/components/RecipeList/RecipeListItem.test.tsx @@ -1,17 +1,24 @@ import { render } from "@solidjs/testing-library"; +import type { JSX } from "solid-js"; import { describe, expect, it, vi } from "vitest"; import RecipeListItem from "./RecipeListItem.jsx"; +vi.mock("@solidjs/router", () => ({ + A: (props: { href: string; class: string; children: JSX.Element }) => ( + + {props.children} + + ), +})); + describe("", () => { it("renders recipe item with all props", () => { - const mockOnSelect = vi.fn(); const { getByText } = render(() => ( )); @@ -21,17 +28,20 @@ describe("", () => { }); it("renders with different difficulty levels", () => { - const mockOnSelect = vi.fn(); const { getByText } = render(() => ( - + )); expect(getByText("Easy")).toBeInTheDocument(); }); + + it("renders as a link to the recipe page", () => { + const { container } = render(() => ( + + )); + + const link = container.querySelector("a"); + expect(link).toBeTruthy(); + expect(link?.getAttribute("href")).toBe("/recipe/test-recipe"); + }); }); diff --git a/src/components/RecipeList/RecipeListItem.tsx b/src/components/RecipeList/RecipeListItem.tsx index 3a89a91..446e9dd 100644 --- a/src/components/RecipeList/RecipeListItem.tsx +++ b/src/components/RecipeList/RecipeListItem.tsx @@ -1,41 +1,23 @@ +import { A } from "@solidjs/router"; import type { Component } from "solid-js"; import styles from "./RecipeListItem.module.css"; type RecipeListItemProps = { - id: string; + urlSlug: string; name: string; difficulty: "Easy" | "Medium" | "Hard" | "Unknown"; time: string; - onSelect: (id: string) => void; }; const RecipeListItem: Component = (props) => { - const handleClick = () => { - props.onSelect(props.id); - }; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Enter" || event.key === " ") { - event.preventDefault(); - props.onSelect(props.id); - } - }; - return ( - + ); }; diff --git a/src/components/RecipeVideo/RecipeVideo.module.css b/src/components/RecipeVideo/RecipeVideo.module.css new file mode 100644 index 0000000..8d526e3 --- /dev/null +++ b/src/components/RecipeVideo/RecipeVideo.module.css @@ -0,0 +1,44 @@ +.wrapper { + margin-top: var(--space-20); + padding-top: var(--space-16); + border-top: 1px solid var(--color-neutral-100); +} + +.header { + display: flex; + flex-direction: column; + align-items: center; + margin-bottom: var(--space-8); +} + +.headerTitle { + font-family: var(--font-family-display); + font-style: italic; + font-size: var(--text-2xl); + margin-bottom: var(--space-2); +} + +.headerLine { + height: 2px; + width: 3rem; + background-color: var(--color-neutral-900); +} + +.videoContainer { + position: relative; + width: 100%; + aspect-ratio: 16 / 9; + background-color: var(--color-neutral-100); + border-radius: var(--radius-xl); + border: 1px solid var(--color-neutral-100); + overflow: hidden; + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1); +} + +.video { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} diff --git a/src/components/RecipeVideo/RecipeVideo.tsx b/src/components/RecipeVideo/RecipeVideo.tsx new file mode 100644 index 0000000..e46636b --- /dev/null +++ b/src/components/RecipeVideo/RecipeVideo.tsx @@ -0,0 +1,28 @@ +import type { Component } from "solid-js"; +import styles from "./RecipeVideo.module.css"; + +type RecipeVideoProps = { + src: string; + title: string; +}; + +const RecipeVideo: Component = (props) => ( +
+
+ Video Tutorial +
+
+
+ {/* biome-ignore lint/a11y/useMediaCaption: captions not always available for recipe videos */} +
+
+); + +export default RecipeVideo; diff --git a/src/components/SearchBar/SearchBar.module.css b/src/components/SearchBar/SearchBar.module.css index 657a9e6..c0d9d65 100644 --- a/src/components/SearchBar/SearchBar.module.css +++ b/src/components/SearchBar/SearchBar.module.css @@ -10,45 +10,42 @@ .searchInput { width: 100%; padding: var(--space-3) var(--space-12) var(--space-3) var(--space-12); - font-size: var(--text-lg); + font-size: var(--text-md); + font-family: var(--font-family-base); color: var(--color-neutral-900); - background-color: var(--color-neutral-100); - border: var(--border-width) solid var(--border-color); + background-color: white; + border: 1px solid var(--color-neutral-300); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); outline: none; transition: all 0.2s ease; } -/* Remove default search input clear button */ .searchInput::-webkit-search-cancel-button, .searchInput::-webkit-search-decoration { -webkit-appearance: none; appearance: none; } +.searchInput::placeholder { + color: var(--color-neutral-400); +} + .searchInput:focus { - border-color: transparent; - box-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring-color); + border-color: var(--color-primary-400); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1); } .searchIcon { position: absolute; - top: 0; - bottom: 0; - left: 0; - display: flex; - align-items: center; - padding-left: var(--space-4); + top: 50%; + left: var(--space-4); + transform: translateY(-50%); + font-size: 20px; + color: var(--color-neutral-400); pointer-events: none; } -.searchIcon svg { - width: 1.25rem; - height: 1.25rem; - color: #9ca3af; -} - .clearButton { position: absolute; top: 50%; @@ -60,30 +57,23 @@ width: 1.75rem; height: 1.75rem; padding: 0; - color: #9ca3af; + color: var(--color-neutral-400); background-color: transparent; border: none; - border-radius: var(--radius-md); + border-radius: var(--radius-full); cursor: pointer; transition: all 0.2s ease; } .clearButton:hover { - color: #6b7280; - background-color: rgba(156, 163, 175, 0.1); + color: var(--color-neutral-600); + background-color: var(--color-neutral-100); } .clearButton:active { transform: translateY(-50%) scale(0.95); } -.clearButton svg { - width: 1rem; - height: 1rem; -} - -.results { - margin-top: var(--space-2); - font-size: var(--text-sm); - color: #6b7280; +.clearButton .material-symbols-outlined { + font-size: 18px; } diff --git a/src/components/SearchBar/SearchBar.tsx b/src/components/SearchBar/SearchBar.tsx index 404089d..6f8a74e 100644 --- a/src/components/SearchBar/SearchBar.tsx +++ b/src/components/SearchBar/SearchBar.tsx @@ -1,4 +1,4 @@ -import { useT } from "~/lib/i18nContext"; +import { strings } from "~/constants/strings"; import type { Recipe } from "~/types/Recipe"; import styles from "./SearchBar.module.css"; @@ -8,9 +8,7 @@ type SearchBarProps = { onSearchChange: (query: string) => void; }; -export default function SearchBar(props: SearchBarProps) { - const t = useT(); - +const SearchBar = (props: SearchBarProps) => { const handleInput = (value: string) => { props.onSearchChange(value); }; @@ -18,38 +16,22 @@ export default function SearchBar(props: SearchBarProps) { return (
+ search handleInput(e.currentTarget.value)} class={styles.searchInput} /> -
- - Search icon - - -
{props.searchQuery && ( )}
); -} +}; + +export default SearchBar; diff --git a/src/components/SortDropdown/SortDropdown.module.css b/src/components/SortDropdown/SortDropdown.module.css index 6bc4724..d9afc59 100644 --- a/src/components/SortDropdown/SortDropdown.module.css +++ b/src/components/SortDropdown/SortDropdown.module.css @@ -1,88 +1,59 @@ .dropdown { position: relative; display: inline-block; - min-width: 220px; } .dropdownButton { - position: relative; display: flex; align-items: center; - justify-content: space-between; - width: 100%; + gap: var(--space-1); padding: var(--space-3) var(--space-4); - background-color: var(--color-neutral-100); - border: var(--border-width) solid var(--border-color); + background-color: white; + border: 1px solid var(--color-neutral-300); border-radius: var(--radius-lg); - font-size: var(--text-base); - color: var(--color-neutral-900); + font-size: var(--text-sm); + color: var(--color-neutral-700); cursor: pointer; transition: all 0.2s ease; - box-shadow: var(--shadow-sm); - min-height: 48px; + white-space: nowrap; } .dropdownButton:hover { - background-color: var(--color-neutral-200); - border-color: var(--border-color); + background-color: var(--color-neutral-50); + border-color: var(--color-neutral-400); } .dropdownButton:focus { outline: none; - border-color: transparent; - box-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring-color); + border-color: var(--color-primary-400); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.1); } .label { - position: absolute; - top: -9px; - left: 16px; - background: var(--color-neutral-100); - padding: 0 var(--space-2); - font-size: var(--text-xs); - font-weight: 600; - color: var(--color-neutral-500); + font-weight: 500; text-transform: uppercase; letter-spacing: 0.05em; + font-size: var(--text-xs); } -.dropdownButton:hover .label { - background-color: var(--color-neutral-200); -} - -.dropdownButton:focus .label { - background-color: var(--color-neutral-100); -} - -.selectedValue { - color: var(--color-neutral-900); - font-weight: 500; - font-size: var(--text-base); - flex: 1; - text-align: left; -} - -.chevron { - width: 20px; - height: 20px; - color: var(--color-neutral-600); +.icon { + font-size: 20px; + color: var(--color-neutral-500); transition: transform 0.2s ease; - flex-shrink: 0; - margin-left: var(--space-2); } -.chevronUp { +.iconRotated { transform: rotate(180deg); } .dropdownMenu { position: absolute; top: 100%; - left: 0; right: 0; margin-top: var(--space-2); + min-width: 200px; background: white; - border: var(--border-width) solid var(--border-color); + border: 1px solid var(--color-neutral-200); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); z-index: 50; @@ -94,29 +65,29 @@ width: 100%; padding: var(--space-3) var(--space-4); text-align: left; - font-size: var(--text-base); - color: var(--color-neutral-900); + font-size: var(--text-sm); + color: var(--color-neutral-700); background: none; border: none; cursor: pointer; - transition: all 0.2s ease; + transition: all 0.15s ease; font-weight: 400; } .dropdownItem:hover { - background-color: var(--color-neutral-100); + background-color: var(--color-neutral-50); color: var(--color-neutral-900); } .dropdownItem:focus { outline: none; - background-color: var(--color-neutral-100); + background-color: var(--color-neutral-50); } .dropdownItemActive { background-color: var(--color-primary-50); color: var(--color-primary-700); - font-weight: 600; + font-weight: 500; } .dropdownItemActive:hover { diff --git a/src/components/SortDropdown/SortDropdown.test.tsx b/src/components/SortDropdown/SortDropdown.test.tsx index 24f257d..3cb48aa 100644 --- a/src/components/SortDropdown/SortDropdown.test.tsx +++ b/src/components/SortDropdown/SortDropdown.test.tsx @@ -3,24 +3,25 @@ import { describe, expect, it, vi } from "vitest"; import SortDropdown from "./SortDropdown.jsx"; describe("", () => { - it("renders with current value", () => { + it("renders with sort by label", () => { const mockOnSortChange = vi.fn(); const { getByText } = render(() => ( )); - expect(getByText("Name (A-Z)")).toBeInTheDocument(); + expect(getByText("Sort by")).toBeInTheDocument(); }); it("shows all sort options when clicked", () => { const mockOnSortChange = vi.fn(); - const { getByText } = render(() => ( + const { getByText, getByRole } = render(() => ( )); - const button = getByText("Name (A-Z)"); + const button = getByRole("button"); fireEvent.click(button); + expect(getByText("Name (A-Z)")).toBeInTheDocument(); expect(getByText("Name (Z-A)")).toBeInTheDocument(); expect(getByText("Time (shortest first)")).toBeInTheDocument(); expect(getByText("Time (longest first)")).toBeInTheDocument(); @@ -30,11 +31,11 @@ describe("", () => { it("calls onSortChange when option is selected", () => { const mockOnSortChange = vi.fn(); - const { getByText } = render(() => ( + const { getByText, getByRole } = render(() => ( )); - const button = getByText("Name (A-Z)"); + const button = getByRole("button"); fireEvent.click(button); const option = getByText("Time (shortest first)"); @@ -43,12 +44,17 @@ describe("", () => { expect(mockOnSortChange).toHaveBeenCalledWith("time-asc"); }); - it("renders with different initial values", () => { + it("highlights the active option", () => { const mockOnSortChange = vi.fn(); - const { getByText } = render(() => ( + const { getByRole, getAllByRole } = render(() => ( )); - expect(getByText("Difficulty (hard first)")).toBeInTheDocument(); + const button = getByRole("button"); + fireEvent.click(button); + + const options = getAllByRole("button"); + const activeOption = options.find((opt) => opt.textContent === "Difficulty (hard first)"); + expect(activeOption).toHaveClass(/_dropdownItemActive/); }); }); diff --git a/src/components/SortDropdown/SortDropdown.tsx b/src/components/SortDropdown/SortDropdown.tsx index 137216a..d46eb16 100644 --- a/src/components/SortDropdown/SortDropdown.tsx +++ b/src/components/SortDropdown/SortDropdown.tsx @@ -1,6 +1,6 @@ import { createSignal } from "solid-js"; import type { SortValue } from "~/constants/sortOptions"; -import { useT } from "~/lib/i18nContext"; +import { strings } from "~/constants/strings"; import styles from "./SortDropdown.module.css"; type SortDropdownProps = { @@ -8,22 +8,18 @@ type SortDropdownProps = { onSortChange: (value: SortValue) => void; }; -export default function SortDropdown(props: SortDropdownProps) { - const t = useT(); +const SortDropdown = (props: SortDropdownProps) => { const [isOpen, setIsOpen] = createSignal(false); const sortOptions = [ - { value: "name-asc", label: t.sort.nameAsc }, - { value: "name-desc", label: t.sort.nameDesc }, - { value: "time-asc", label: t.sort.timeAsc }, - { value: "time-desc", label: t.sort.timeDesc }, - { value: "difficulty-easy", label: t.sort.difficultyEasy }, - { value: "difficulty-hard", label: t.sort.difficultyHard }, + { value: "name-asc", label: strings.sort.nameAsc }, + { value: "name-desc", label: strings.sort.nameDesc }, + { value: "time-asc", label: strings.sort.timeAsc }, + { value: "time-desc", label: strings.sort.timeDesc }, + { value: "difficulty-easy", label: strings.sort.difficultyEasy }, + { value: "difficulty-hard", label: strings.sort.difficultyHard }, ] as const; - const selectedOption = () => - sortOptions.find((option) => option.value === props.value) || sortOptions[0]; - const handleOptionClick = (value: SortValue) => { props.onSortChange(value); setIsOpen(false); @@ -42,22 +38,12 @@ export default function SortDropdown(props: SortDropdownProps) { aria-haspopup="true" aria-expanded={isOpen()} > - {t.sort.label} - {selectedOption().label} - {strings.sort.label} + - Sort icon - - + expand_more + {isOpen() && ( @@ -75,4 +61,6 @@ export default function SortDropdown(props: SortDropdownProps) { )}
); -} +}; + +export default SortDropdown; diff --git a/src/constants/difficultyOptions.ts b/src/constants/difficultyOptions.ts index 9a5038b..00a180b 100644 --- a/src/constants/difficultyOptions.ts +++ b/src/constants/difficultyOptions.ts @@ -1,18 +1,4 @@ -import type { HandleNullString } from "~/utils/types"; +export const difficultyValues = ["Easy", "Medium", "Hard"] as const; -// Difficulty definitions with translation key mappings -export const difficultyDefinitions = { - null: "all", - Easy: "easy", - Medium: "medium", - Hard: "hard", -} as const; - -export const difficultyValues = Object.keys(difficultyDefinitions).map((key) => - key === "null" ? null : key, -) as ReadonlyArray; - -export type DifficultyFilter = HandleNullString; - -export type DifficultyTranslationKey = - (typeof difficultyDefinitions)[keyof typeof difficultyDefinitions]; +export type DifficultyValue = (typeof difficultyValues)[number]; +export type DifficultyFilter = ReadonlyArray; diff --git a/src/constants/filterOptions.ts b/src/constants/filterOptions.ts index d048bf9..aaf07e5 100644 --- a/src/constants/filterOptions.ts +++ b/src/constants/filterOptions.ts @@ -1,29 +1,45 @@ -// Unified filter options with helper functions -import { - type DifficultyFilter, - type DifficultyTranslationKey, - difficultyDefinitions, -} from "./difficultyOptions.js"; -import { type TagFilter, type TagTranslationKey, tagDefinitions } from "./tagOptions.js"; -import { type TimeFilter, type TimeTranslationKey, timeDefinitions } from "./timeOptions.js"; +import { type DifficultyValue, difficultyValues } from "./difficultyOptions.js"; +import { strings } from "./strings.js"; +import { type TagValue, tagValues } from "./tagOptions.js"; +import { type TimeValue, timeValues } from "./timeOptions.js"; -export const createTagOptions = (t: { tags: Record }) => { - return Object.entries(tagDefinitions).map(([value, key]) => ({ - value: value === "null" ? null : value, - label: t.tags[key], - })) as Array<{ value: TagFilter; label: T }>; +const difficultyLabels: Record = { + Easy: strings.filters.easy, + Medium: strings.filters.medium, + Hard: strings.filters.hard, }; -export const createDifficultyOptions = (t: { filters: Record }) => { - return Object.entries(difficultyDefinitions).map(([value, key]) => ({ - value: value === "null" ? null : value, - label: t.filters[key], - })) as Array<{ value: DifficultyFilter; label: T }>; +const timeLabels: Record = { + under30: strings.filters.under30, + under60: strings.filters.under60, + over60: strings.filters.over60, }; -export const createTimeOptions = (t: { filters: Record }) => { - return Object.entries(timeDefinitions).map(([value, key]) => ({ - value: value === "null" ? null : value, - label: t.filters[key], - })) as Array<{ value: TimeFilter; label: T }>; +const tagLabels: Record = { + Chicken: strings.tags.chicken, + Pork: strings.tags.pork, + Beef: strings.tags.beef, + Fish: strings.tags.fish, + Vegan: strings.tags.vegan, + Dessert: strings.tags.dessert, + "Lactose-free": strings.tags.lactoseFree, + "Low-Sugar": strings.tags.lowSugar, + Cake: strings.tags.cake, + Vegetarian: strings.tags.vegetarian, + Eggs: strings.tags.eggs, }; + +export const difficultyOptions = difficultyValues.map((value) => ({ + value, + label: difficultyLabels[value], +})); + +export const timeOptions = timeValues.map((value) => ({ + value, + label: timeLabels[value], +})); + +export const tagOptions = tagValues.map((value) => ({ + value, + label: tagLabels[value], +})); diff --git a/src/i18n/translations/en.ts b/src/constants/strings.ts similarity index 78% rename from src/i18n/translations/en.ts rename to src/constants/strings.ts index 0c829fd..e75cd1e 100644 --- a/src/i18n/translations/en.ts +++ b/src/constants/strings.ts @@ -1,7 +1,4 @@ -import { createStore } from "solid-js/store"; -import type { TypedTranslations } from "~/constants/translationTypes"; - -export const enTranslations = createStore({ +export const strings = { app: { title: "Cookmark", }, @@ -14,7 +11,6 @@ export const enTranslations = createStore({ difficulty: "Difficulty", time: "Time", tags: "Tags", - all: "All", easy: "Easy", medium: "Medium", hard: "Hard", @@ -40,9 +36,11 @@ export const enTranslations = createStore({ ingredients: "Ingredients", instructions: "Instructions", viewSource: "View Recipe Source", + difficultyLabel: "Difficulty", + notFound: "Recipe not found", + backToList: "Back to recipes", }, tags: { - all: "All", chicken: "Chicken", pork: "Pork", beef: "Beef", @@ -52,10 +50,18 @@ export const enTranslations = createStore({ lactoseFree: "Lactose-free", lowSugar: "Low-Sugar", cake: "Cake", + vegetarian: "Vegetarian", + eggs: "Eggs", }, - languageSwitcher: { - switchToEnglish: "Switch to English", - switchToSlovak: "Switch to Slovak", + filterDrawer: { + title: "Filters", + clearAll: "Clear all", + filtersButton: "Filters", + }, + recipeList: { + name: "Recipe Name", + difficulty: "Difficulty", + time: "Time", }, pagination: { previous: "Previous", @@ -63,4 +69,4 @@ export const enTranslations = createStore({ goToPage: (page: number) => `Go to page ${page}`, pageInfo: (current: number, total: number) => `Page ${current} of ${total}`, }, -}); +} as const; diff --git a/src/constants/tagOptions.ts b/src/constants/tagOptions.ts index fda9271..ebf9cfb 100644 --- a/src/constants/tagOptions.ts +++ b/src/constants/tagOptions.ts @@ -1,22 +1,16 @@ -import type { HandleNullString } from "~/utils/types"; +export const tagValues = [ + "Chicken", + "Pork", + "Beef", + "Fish", + "Vegan", + "Dessert", + "Lactose-free", + "Low-Sugar", + "Cake", + "Vegetarian", + "Eggs", +] as const; -// Tag definitions with translation key mappings -export const tagDefinitions = { - null: "all", - Chicken: "chicken", - Pork: "pork", - Beef: "beef", - Fish: "fish", - Vegan: "vegan", - Dessert: "dessert", - "Lactose-free": "lactoseFree", - "Low-Sugar": "lowSugar", - Cake: "cake", -} as const; - -export const tagValues = Object.keys(tagDefinitions).map((key) => - key === "null" ? null : key, -) as ReadonlyArray; - -export type TagFilter = HandleNullString; -export type TagTranslationKey = (typeof tagDefinitions)[keyof typeof tagDefinitions]; +export type TagValue = (typeof tagValues)[number]; +export type TagFilter = ReadonlyArray; diff --git a/src/constants/timeOptions.ts b/src/constants/timeOptions.ts index d55f16b..07f96e9 100644 --- a/src/constants/timeOptions.ts +++ b/src/constants/timeOptions.ts @@ -1,14 +1,4 @@ -// Time filter definitions with translation key mappings -export const timeDefinitions = { - null: "all", - under30: "under30", - under60: "under60", - over60: "over60", -} as const; +export const timeValues = ["under30", "under60", "over60"] as const; -export const timeValues = Object.keys(timeDefinitions).map((key) => - key === "null" ? null : key, -) as ReadonlyArray; - -export type TimeFilter = (typeof timeValues)[number]; -export type TimeTranslationKey = (typeof timeDefinitions)[keyof typeof timeDefinitions]; +export type TimeValue = (typeof timeValues)[number]; +export type TimeFilter = ReadonlyArray; diff --git a/src/constants/translationTypes.ts b/src/constants/translationTypes.ts deleted file mode 100644 index cf5c61e..0000000 --- a/src/constants/translationTypes.ts +++ /dev/null @@ -1,57 +0,0 @@ -// Translation type definitions for compile-time validation -import type { DifficultyTranslationKey } from "./difficultyOptions.js"; -import type { TagTranslationKey } from "./tagOptions.js"; -import type { TimeTranslationKey } from "./timeOptions.js"; - -// Required filter translation keys -export type FilterTranslations = Record & { - difficulty: string; - time: string; - tags: string; -}; - -// Required tag translation keys -export type TagTranslations = Record; - -// Complete translation structure with typed filter sections -export type TypedTranslations = { - app: { - title: string; - }; - search: { - placeholder: string; - noResults: string; - resultsCount: (count: number) => string; - }; - filters: FilterTranslations; - sort: { - label: string; - nameAsc: string; - nameDesc: string; - timeAsc: string; - timeDesc: string; - difficultyEasy: string; - difficultyHard: string; - }; - recipe: { - prep: string; - cook: string; - total: string; - servings: string; - cuisine: string; - ingredients: string; - instructions: string; - viewSource: string; - }; - tags: TagTranslations; - languageSwitcher: { - switchToEnglish: string; - switchToSlovak: string; - }; - pagination: { - previous: string; - next: string; - goToPage: (page: number) => string; - pageInfo: (current: number, total: number) => string; - }; -}; diff --git a/src/i18n.ts b/src/i18n.ts deleted file mode 100644 index 3a38a63..0000000 --- a/src/i18n.ts +++ /dev/null @@ -1,9 +0,0 @@ -export type Locale = "en" | "sk"; - -import { enTranslations } from "./i18n/translations/en.js"; -import { skTranslations } from "./i18n/translations/sk.js"; - -export const translations = { - en: enTranslations, - sk: skTranslations, -} as const; diff --git a/src/i18n/translations/sk.ts b/src/i18n/translations/sk.ts deleted file mode 100644 index ba05ec6..0000000 --- a/src/i18n/translations/sk.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { createStore } from "solid-js/store"; -import type { TypedTranslations } from "~/constants/translationTypes"; - -export const skTranslations = createStore({ - app: { - title: "Cookmark", - }, - search: { - placeholder: "Hľadať recepty...", - noResults: "Neboli nájdené žiadne recepty", - resultsCount: (count: number) => { - if (count === 1) { - return "1 recept nájdený"; - } - if (count >= 2 && count <= 4) { - return `${count} recepty nájdené`; - } - return `${count} receptov nájdených`; - }, - }, - filters: { - difficulty: "Obtiažnosť", - time: "Čas", - tags: "Štítky", - all: "Všetko", - easy: "Ľahké", - medium: "Stredné", - hard: "Ťažké", - under30: "Do 30 minút", - under60: "Do 1 hodiny", - over60: "Viac ako 1 hodina", - }, - sort: { - label: "Zoradiť podľa", - nameAsc: "Názov (A-Z)", - nameDesc: "Názov (Z-A)", - timeAsc: "Čas (najkratší prvý)", - timeDesc: "Čas (najdlhší prvý)", - difficultyEasy: "Obtiažnosť (ľahké prvé)", - difficultyHard: "Obtiažnosť (ťažké prvé)", - }, - recipe: { - prep: "Príprava:", - cook: "Varenie:", - total: "Celkovo:", - servings: "Porcie:", - cuisine: "Kuchyňa:", - ingredients: "Prísady", - instructions: "Postup", - viewSource: "Zobraziť zdroj receptu", - }, - tags: { - all: "Všetko", - chicken: "Kura", - pork: "Bravčové", - beef: "Hovädzie", - fish: "Ryba", - vegan: "Vegánske", - dessert: "Dezert", - lactoseFree: "Bez laktózy", - lowSugar: "Málo cukru", - cake: "Koláč", - }, - languageSwitcher: { - switchToEnglish: "Prepnúť na angličtinu", - switchToSlovak: "Prepnúť na slovenčinu", - }, - pagination: { - previous: "Predchádzajúca", - next: "Ďalšia", - goToPage: (page: number) => `Prejsť na stranu ${page}`, - pageInfo: (current: number, total: number) => `Strana ${current} z ${total}`, - }, -}); diff --git a/src/lib/i18nContext.tsx b/src/lib/i18nContext.tsx deleted file mode 100644 index fc3cd2f..0000000 --- a/src/lib/i18nContext.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import type { JSX } from "solid-js"; -import { createContext, useContext } from "solid-js"; -import type { TypedTranslations } from "~/constants/translationTypes.js"; -import { type Locale, translations } from "~/i18n"; - -const I18nContext = createContext(translations.en[0]); - -export function I18nProvider(props: { locale: Locale; children: JSX.Element }) { - return ( - - {props.children} - - ); -} - -export function useT(): TypedTranslations { - const context = useContext(I18nContext); - if (!context) { - throw new Error("useT must be used within I18nProvider"); - } - return context; -} diff --git a/src/routes/[lang].tsx b/src/routes/[lang].tsx deleted file mode 100644 index 97ba412..0000000 --- a/src/routes/[lang].tsx +++ /dev/null @@ -1,257 +0,0 @@ -import { useParams, useSearchParams } from "@solidjs/router"; -import Fuse from "fuse.js"; -import { createMemo } from "solid-js"; -import FilterSidebar from "~/components/FilterSidebar/FilterSidebar"; -import LanguageSwitcher from "~/components/LanguageSwitcher/LanguageSwitcher"; -import RecipeDrawer from "~/components/RecipeDrawer/RecipeDrawer"; -import RecipeList from "~/components/RecipeList/RecipeList"; -import SearchBar from "~/components/SearchBar/SearchBar"; -import SortDropdown from "~/components/SortDropdown/SortDropdown"; -import { type DifficultyFilter, difficultyValues } from "~/constants/difficultyOptions"; -import { type SortValue, sortValues } from "~/constants/sortOptions"; -import { type TagFilter, tagValues } from "~/constants/tagOptions"; -import { type TimeFilter, timeValues } from "~/constants/timeOptions"; -import type { Locale } from "~/i18n"; -import { I18nProvider, useT } from "~/lib/i18nContext"; -import type { Recipe } from "~/types/Recipe"; -import { loadRecipes } from "~/utils/loadRecipes"; -import styles from "./index.module.css"; - -function Home() { - const t = useT(); - const recipes: Recipe[] = loadRecipes(); - const [searchParams, setSearchParams] = useSearchParams(); - - // Derive all filters from URL params - const difficultyFilter = createMemo((): DifficultyFilter => { - if (!searchParams.difficulty || Array.isArray(searchParams.difficulty)) { - return null; - } - return difficultyValues.includes(searchParams.difficulty) - ? (searchParams.difficulty as DifficultyFilter) - : null; - }); - - const timeFilter = createMemo((): TimeFilter => { - if (!searchParams.time || Array.isArray(searchParams.time)) { - return null; - } - return timeValues.includes(searchParams.time) ? (searchParams.time as TimeFilter) : null; - }); - - const tagFilter = createMemo(() => { - if (!searchParams.tag || Array.isArray(searchParams.tag)) { - return null; - } - return tagValues.includes(searchParams.tag) ? (searchParams.tag as TagFilter) : null; - }); - - const sortBy = createMemo(() => { - const value = searchParams.sort as SortValue; - - return sortValues.includes(value) ? value : "name-asc"; - }); - - const searchQuery = createMemo(() => searchParams.q || ""); - - // Simple page state from URL - const currentPage = createMemo(() => { - const pageParam = searchParams.page; - if (!pageParam || Array.isArray(pageParam)) { - return 1; - } - const page = parseInt(pageParam, 10); - return Number.isNaN(page) || page < 1 ? 1 : page; - }); - - // Create Fuse instance for search - const fuse = createMemo(() => { - return new Fuse(recipes, { - keys: [ - { name: "name", weight: 0.7 }, - { name: "difficulty", weight: 0.2 }, - { name: "time", weight: 0.1 }, - ], - threshold: 0.3, - includeScore: true, - minMatchCharLength: 2, - }); - }); - - // Create sort comparator function - const getSortComparator = (sortBy: SortValue) => { - switch (sortBy) { - case "name-asc": - return (a: Recipe, b: Recipe) => a.name.localeCompare(b.name); - case "name-desc": - return (a: Recipe, b: Recipe) => b.name.localeCompare(a.name); - case "time-asc": - return (a: Recipe, b: Recipe) => a.total_time - b.total_time; - case "time-desc": - return (a: Recipe, b: Recipe) => b.total_time - a.total_time; - case "difficulty-easy": { - const difficultyOrder = { Easy: 0, Medium: 1, Hard: 2, Unknown: 3 }; - return (a: Recipe, b: Recipe) => - difficultyOrder[a.difficulty] - difficultyOrder[b.difficulty]; - } - case "difficulty-hard": { - const difficultyOrderReverse = { Hard: 0, Medium: 1, Easy: 2, Unknown: 3 }; - return (a: Recipe, b: Recipe) => - difficultyOrderReverse[a.difficulty] - difficultyOrderReverse[b.difficulty]; - } - default: - return (a: Recipe, b: Recipe) => a.name.localeCompare(b.name); - } - }; - - // Filtered recipes using functional chaining - const filteredRecipes = createMemo(() => { - const query = searchQuery(); - const difficulty = difficultyFilter(); - const time = timeFilter(); - const tag = tagFilter(); - const sort = sortBy(); - - const baseRecipes = - query && typeof query === "string" && query.trim() - ? fuse() - .search(query.trim()) - .map((result) => result.item) - : recipes; - - return baseRecipes - .filter((recipe) => !difficulty || recipe.difficulty === difficulty) - .filter((recipe) => { - if (!time) { - return true; - } - switch (time) { - case "under30": - return recipe.total_time < 30; - case "under60": - return recipe.total_time < 60; - case "over60": - return recipe.total_time >= 60; - default: - return true; - } - }) - .filter((recipe) => !tag || recipe.tags.includes(tag)) - .sort(getSortComparator(sort)); - }); - - // Derive selected recipe from URL - const selectedRecipeId = createMemo(() => { - const slug = searchParams.recipe; - if (!slug) { - return null; - } - const recipe = recipes.find((r) => r.url_slug === slug); - return recipe?.id ?? null; - }); - - // Drawer is open when recipe is selected - const isDrawerOpen = createMemo(() => selectedRecipeId() !== null); - - const handleRecipeSelect = (id: string) => { - const recipe = recipes.find((r) => r.id === id); - if (recipe) { - setSearchParams({ ...searchParams, recipe: recipe.url_slug }); - } - }; - - const handleDrawerClose = () => { - setSearchParams({ ...searchParams, recipe: undefined }); - }; - - const handleDifficultyFilter = (difficulty: DifficultyFilter) => { - setSearchParams({ ...searchParams, difficulty: difficulty ?? undefined, page: undefined }); - }; - - const handleTimeFilter = (time: TimeFilter) => { - setSearchParams({ ...searchParams, time: time ?? undefined, page: undefined }); - }; - - const handleTagFilter = (tag: TagFilter) => { - setSearchParams({ ...searchParams, tag: tag ?? undefined, page: undefined }); - }; - - const handleSortChange = (sort: SortValue) => { - setSearchParams({ - ...searchParams, - sort: sort !== "name-asc" ? sort : undefined, - page: undefined, - }); - }; - - const handleSearchChange = (query: string) => { - setSearchParams({ ...searchParams, q: query ?? undefined, page: undefined }); - }; - - const handlePageChange = (details: { page: number }) => { - if (details.page === 1) { - setSearchParams({ ...searchParams, page: undefined }); - } else { - setSearchParams({ ...searchParams, page: details.page.toString() }); - } - }; - - return ( -
-
-
-

{t.app.title}

- -
-
-
-
- -
-
- - -
- -
-
-
- - { - if (!open) { - handleDrawerClose(); - } - }} - recipeId={selectedRecipeId()} - onClose={handleDrawerClose} - /> -
- ); -} - -export default function LangLayout() { - const params = useParams<{ lang: Locale }>(); - return ( - - - - ); -} diff --git a/src/routes/index.module.css b/src/routes/index.module.css index f40d4ab..5cebfe1 100644 --- a/src/routes/index.module.css +++ b/src/routes/index.module.css @@ -1,56 +1,53 @@ .main { margin: 0 auto; font-family: var(--font-family-base); - background-color: var(--color-neutral-100); + background-color: white; min-height: 100vh; } .header { width: 100%; - padding: var(--space-12) var(--space-8) var(--space-10); - background: linear-gradient(135deg, var(--color-primary-300) 0%, var(--color-primary-400) 100%); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + padding: var(--space-8) var(--space-8) var(--space-6); + background: white; + border-bottom: 1px solid var(--color-neutral-200); position: relative; - overflow: hidden; } .headerContent { max-width: 1200px; margin: 0 auto; display: flex; - justify-content: center; + flex-direction: column; align-items: center; position: relative; } -.header::before { - content: ""; - position: absolute; - top: -50%; - right: -10%; - width: 50%; - height: 200%; - background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 70%); - transform: rotate(45deg); -} - .title { + font-family: var(--font-family-display); color: var(--color-neutral-900); text-transform: uppercase; - font-size: var(--text-6xl); + font-size: var(--text-5xl); line-height: 1; - font-weight: 300; - letter-spacing: 0.05em; + font-weight: 400; + letter-spacing: 0.1em; margin: 0; - position: relative; - z-index: 1; - flex: 1; text-align: center; } -.headerContent > :last-child { +.subtitle { + font-family: var(--font-family-display); + font-style: italic; + font-size: var(--text-lg); + color: var(--color-neutral-500); + margin: var(--space-2) 0 0 0; + font-weight: 400; +} + +.headerContent > :last-child:not(.subtitle) { position: absolute; right: 0; + top: 50%; + transform: translateY(-50%); z-index: 2; } @@ -58,99 +55,167 @@ width: 100%; max-width: 1200px; margin: 0 auto; - padding: var(--space-12) var(--space-8); + padding: var(--space-8) var(--space-8); } .layout { display: flex; - gap: 0; - align-items: stretch; - min-height: 600px; + flex-direction: column; + gap: var(--space-6); } -.mainContent { - flex: 1; - padding-left: var(--space-6); +.controls { display: flex; - flex-direction: column; + gap: var(--space-4); align-items: center; - gap: var(--space-8); + width: 100%; } -.searchAndSort { - width: 100%; +.filtersButton { display: flex; - gap: var(--space-8); align-items: center; - justify-content: space-between; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + background: white; + border: 1px solid var(--color-neutral-300); + border-radius: var(--radius-lg); + font-size: var(--text-sm); + font-weight: 500; + color: var(--color-neutral-700); + cursor: pointer; + transition: all 0.15s ease-in-out; + white-space: nowrap; } -.searchAndSort > *:first-child { - flex: 1; - max-width: 70%; +.filtersButton:hover { + background: var(--color-neutral-50); + border-color: var(--color-neutral-400); } -.searchAndSort > *:last-child { - flex-shrink: 0; +.filtersButton:focus { + outline: var(--focus-ring-width) solid var(--focus-ring-color); + outline-offset: var(--focus-ring-offset); } -/* Responsive design */ -@media (max-width: 1024px) { - .container { - padding: var(--space-12) var(--space-20); - } +.filtersButtonActive { + background: var(--color-neutral-900); + border-color: var(--color-neutral-900); + color: white; +} + +.filtersButtonActive:hover { + background: var(--color-neutral-800); + border-color: var(--color-neutral-800); +} + +.filterCount { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 var(--space-1); + background: white; + color: var(--color-neutral-900); + font-size: 11px; + font-weight: 600; + border-radius: var(--radius-full); +} + +.clearFiltersButton { + display: flex; + align-items: center; + gap: var(--space-1); + padding: var(--space-2) var(--space-3); + background: transparent; + border: none; + font-size: var(--text-sm); + font-weight: 500; + color: var(--color-neutral-500); + cursor: pointer; + transition: color 0.15s ease; + white-space: nowrap; +} + +.clearFiltersButton:hover { + color: var(--color-neutral-900); +} + +.clearFiltersButton .material-symbols-outlined { + font-size: 18px; +} + +.searchWrapper { + flex: 1; +} + +.mainContent { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--space-6); } @media (max-width: 768px) { .container { - padding: var(--space-12) var(--space-8); + padding: var(--space-6) var(--space-4); } - .layout { - flex-direction: column; - gap: 0; - align-items: stretch; - min-height: auto; + .title { + font-size: var(--text-4xl); } - .mainContent { - padding-left: 0; - padding-top: var(--space-4); + .subtitle { + font-size: var(--text-md); } - .searchAndSort { - flex-direction: column; - gap: var(--space-4); + .header { + padding: var(--space-6) var(--space-4) var(--space-4); } - .searchAndSort > *:first-child, - .searchAndSort > *:last-child { - flex: none; + .controls { + flex-wrap: wrap; } - .title { - font-size: var(--text-5xl); + .searchWrapper { + order: 1; + flex: 1 1 100%; } - .header { - padding: var(--space-12) var(--space-6) var(--space-10); + .filtersButton { + order: 2; } - .headerContent { - padding: 0; + .controls > :last-child { + order: 3; + margin-left: auto; } } @media (max-width: 480px) { .container { - padding: var(--space-8) var(--space-6); + padding: var(--space-4) var(--space-3); } .title { - font-size: var(--text-4xl); + font-size: var(--text-3xl); + } + + .subtitle { + font-size: var(--text-sm); } .header { - padding: var(--space-8) var(--space-4); + padding: var(--space-4) var(--space-3); + } + + .headerContent > :last-child:not(.subtitle) { + position: relative; + transform: none; + margin-top: var(--space-4); + } + + .controls { + gap: var(--space-3); } } diff --git a/src/routes/index.tsx b/src/routes/index.tsx index f77ec1b..b1385ce 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,16 +1,264 @@ -import { Meta, Title } from "@solidjs/meta"; +import { useSearchParams } from "@solidjs/router"; +import Fuse from "fuse.js"; +import { createMemo, createSignal, Show } from "solid-js"; +import FilterDrawer from "~/components/FilterDrawer/FilterDrawer"; +import RecipeList from "~/components/RecipeList/RecipeList"; +import SearchBar from "~/components/SearchBar/SearchBar"; +import SortDropdown from "~/components/SortDropdown/SortDropdown"; +import { + type DifficultyFilter, + type DifficultyValue, + difficultyValues, +} from "~/constants/difficultyOptions"; +import { type SortValue, sortValues } from "~/constants/sortOptions"; +import { strings } from "~/constants/strings"; +import { type TagFilter, type TagValue, tagValues } from "~/constants/tagOptions"; +import { type TimeFilter, type TimeValue, timeValues } from "~/constants/timeOptions"; +import type { Recipe } from "~/types/Recipe"; +import { loadRecipes } from "~/utils/loadRecipes"; +import styles from "./index.module.css"; + +const parseArrayParam = ( + param: string | string[] | undefined, + validValues: ReadonlyArray, +): ReadonlyArray => { + if (!param) { + return []; + } + const values = Array.isArray(param) ? param : param.split(","); + return values.filter((v): v is T => validValues.includes(v as T)); +}; + +const serializeArrayParam = (values: ReadonlyArray): string | undefined => + values.length > 0 ? values.join(",") : undefined; + +const Home = () => { + const recipes: Recipe[] = loadRecipes(); + const [searchParams, setSearchParams] = useSearchParams(); + const [isFilterDrawerOpen, setIsFilterDrawerOpen] = createSignal(false); + + const difficultyFilter = createMemo( + (): DifficultyFilter => + parseArrayParam(searchParams.difficulty, difficultyValues as ReadonlyArray), + ); + + const timeFilter = createMemo( + (): TimeFilter => parseArrayParam(searchParams.time, timeValues as ReadonlyArray), + ); + + const tagFilter = createMemo( + (): TagFilter => parseArrayParam(searchParams.tag, tagValues as ReadonlyArray), + ); + + const sortBy = createMemo(() => { + const value = searchParams.sort as SortValue; + return sortValues.includes(value) ? value : "name-asc"; + }); + + const searchQuery = createMemo(() => searchParams.q || ""); + + const currentPage = createMemo(() => { + const pageParam = searchParams.page; + if (!pageParam || Array.isArray(pageParam)) { + return 1; + } + const page = Number.parseInt(pageParam, 10); + return Number.isNaN(page) || page < 1 ? 1 : page; + }); + + const fuse = createMemo( + () => + new Fuse(recipes, { + keys: [ + { name: "name", weight: 0.7 }, + { name: "difficulty", weight: 0.2 }, + { name: "time", weight: 0.1 }, + ], + threshold: 0.3, + includeScore: true, + minMatchCharLength: 2, + }), + ); + + const getSortComparator = (sortBy: SortValue) => { + switch (sortBy) { + case "name-asc": + return (a: Recipe, b: Recipe) => a.name.localeCompare(b.name); + case "name-desc": + return (a: Recipe, b: Recipe) => b.name.localeCompare(a.name); + case "time-asc": + return (a: Recipe, b: Recipe) => a.total_time - b.total_time; + case "time-desc": + return (a: Recipe, b: Recipe) => b.total_time - a.total_time; + case "difficulty-easy": { + const difficultyOrder = { Easy: 0, Medium: 1, Hard: 2, Unknown: 3 }; + return (a: Recipe, b: Recipe) => + difficultyOrder[a.difficulty] - difficultyOrder[b.difficulty]; + } + case "difficulty-hard": { + const difficultyOrderReverse = { Hard: 0, Medium: 1, Easy: 2, Unknown: 3 }; + return (a: Recipe, b: Recipe) => + difficultyOrderReverse[a.difficulty] - difficultyOrderReverse[b.difficulty]; + } + default: + return (a: Recipe, b: Recipe) => a.name.localeCompare(b.name); + } + }; + + const filteredRecipes = createMemo(() => { + const query = searchQuery(); + const difficulties = difficultyFilter(); + const times = timeFilter(); + const tags = tagFilter(); + const sort = sortBy(); + + const baseRecipes = + query && typeof query === "string" && query.trim() + ? fuse() + .search(query.trim()) + .map((result) => result.item) + : recipes; + + return baseRecipes + .filter( + (recipe) => + difficulties.length === 0 || difficulties.includes(recipe.difficulty as DifficultyValue), + ) + .filter((recipe) => { + if (times.length === 0) { + return true; + } + return times.some((time) => { + switch (time) { + case "under30": + return recipe.total_time < 30; + case "under60": + return recipe.total_time < 60; + case "over60": + return recipe.total_time >= 60; + default: + return true; + } + }); + }) + .filter((recipe) => tags.length === 0 || tags.some((tag) => recipe.tags.includes(tag))) + .sort(getSortComparator(sort)); + }); + + const activeFilterCount = createMemo( + () => difficultyFilter().length + timeFilter().length + tagFilter().length, + ); + + const handleDifficultyFilter = (difficulty: DifficultyFilter) => { + setSearchParams({ + ...searchParams, + difficulty: serializeArrayParam(difficulty), + page: undefined, + }); + }; + + const handleTimeFilter = (time: TimeFilter) => { + setSearchParams({ ...searchParams, time: serializeArrayParam(time), page: undefined }); + }; + + const handleTagFilter = (tag: TagFilter) => { + setSearchParams({ ...searchParams, tag: serializeArrayParam(tag), page: undefined }); + }; + + const handleClearAllFilters = () => { + setSearchParams({ + ...searchParams, + difficulty: undefined, + time: undefined, + tag: undefined, + page: undefined, + }); + }; + + const handleSortChange = (sort: SortValue) => { + setSearchParams({ + ...searchParams, + sort: sort !== "name-asc" ? sort : undefined, + page: undefined, + }); + }; + + const handleSearchChange = (query: string) => { + setSearchParams({ ...searchParams, q: query || undefined, page: undefined }); + }; + + const handlePageChange = (details: { page: number }) => { + if (details.page === 1) { + setSearchParams({ ...searchParams, page: undefined }); + } else { + setSearchParams({ ...searchParams, page: details.page.toString() }); + } + }; -export default function RootRedirect() { return ( - <> - Presmerovanie na Knihu receptov - - - - -
- +
+
+
+

{strings.app.title}

+

The minimalist kitchen collection

+
+
+
+
+
+ + 0}> + + +
+ +
+ +
+
+ +
+
- + + +
); -} +}; + +export default Home; diff --git a/src/routes/recipe/[slug].module.css b/src/routes/recipe/[slug].module.css new file mode 100644 index 0000000..cb88c3b --- /dev/null +++ b/src/routes/recipe/[slug].module.css @@ -0,0 +1,364 @@ +.page { + min-height: 100vh; + background-color: var(--color-neutral-50); +} + +.nav { + position: sticky; + top: 0; + z-index: 50; + width: 100%; + border-bottom: 1px solid var(--color-neutral-200); + background-color: rgba(255, 255, 255, 0.95); + backdrop-filter: blur(8px); +} + +.navInner { + max-width: 56rem; + margin: 0 auto; + padding: 0 var(--space-4); + display: flex; + justify-content: space-between; + align-items: center; + height: 4rem; +} + +.logo { + font-weight: 300; + font-size: var(--text-2xl); + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--color-neutral-900); + text-decoration: none; +} + +.navActions { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.navButton { + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-2); + border-radius: var(--radius-full); + color: var(--color-neutral-500); + text-decoration: none; + transition: + background-color 0.2s, + color 0.2s; +} + +.navButton:hover { + background-color: var(--color-neutral-100); + color: var(--color-neutral-700); +} + +.main { + max-width: 56rem; + margin: 0 auto; + padding: var(--space-10) var(--space-4); +} + +@media (min-width: 640px) { + .main { + padding: var(--space-16) var(--space-6); + } +} + +.header { + margin-bottom: var(--space-12); +} + +.headerTop { + margin-bottom: var(--space-8); +} + +.title { + font-size: var(--text-3xl); + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.025em; + color: var(--color-neutral-900); + margin: 0 0 var(--space-4) 0; +} + +@media (min-width: 640px) { + .title { + font-size: var(--text-4xl); + } +} + +@media (min-width: 768px) { + .title { + font-size: 3rem; + } +} + +.description { + font-size: var(--text-lg); + font-weight: 300; + line-height: 1.6; + color: var(--color-neutral-500); + margin: 0; +} + +.metaBar { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-6); + padding: var(--space-6) 0; + border-top: 1px solid var(--color-neutral-200); + border-bottom: 1px solid var(--color-neutral-200); +} + +@media (min-width: 768px) { + .metaBar { + grid-template-columns: repeat(4, 1fr); + } +} + +.metaItem { + display: flex; + flex-direction: column; + gap: var(--space-1); +} + +.metaLabel { + font-size: var(--text-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-neutral-500); +} + +.metaValue { + font-size: var(--text-sm); + font-weight: 500; + color: var(--color-neutral-900); +} + +.difficultyBadge { + display: inline-flex; + align-items: center; + padding: 2px var(--space-2); + border-radius: var(--radius-full); + font-size: 11px; + font-weight: 500; + width: fit-content; +} + +.difficultyEasy { + background-color: #dcfce7; + color: #166534; +} + +.difficultyMedium { + background-color: #fef3c7; + color: #92400e; +} + +.difficultyHard { + background-color: #fee2e2; + color: #991b1b; +} + +.difficultyUnknown { + background-color: var(--color-neutral-200); + color: var(--color-neutral-600); +} + +.tagsRow { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + margin-top: var(--space-6); +} + +.tagsList { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.tag { + display: inline-flex; + align-items: center; + padding: var(--space-1) var(--space-3); + background-color: var(--color-neutral-100); + color: var(--color-neutral-800); + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-weight: 500; +} + +.sourceLink { + display: inline-flex; + align-items: center; + gap: var(--space-1); + font-size: var(--text-sm); + font-weight: 500; + color: var(--color-neutral-500); + text-decoration: none; + transition: color 0.2s; +} + +.sourceLink:hover { + color: var(--color-neutral-900); +} + +.sourceLink .material-symbols-outlined { + font-size: 1rem; + transition: transform 0.2s; +} + +.sourceLink:hover .material-symbols-outlined { + transform: translateX(2px); +} + +.content { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-12); +} + +@media (min-width: 1024px) { + .content { + grid-template-columns: 5fr 7fr; + gap: var(--space-16); + } +} + +.sectionTitle { + font-size: var(--text-2xl); + font-weight: 700; + color: var(--color-neutral-900); + margin: 0 0 var(--space-8) 0; + padding-bottom: var(--space-2); + border-bottom: 2px solid var(--color-neutral-900); + display: inline-block; +} + +.ingredientsList { + list-style: none; + padding: 0; + margin: 0; +} + +.ingredientItem { + padding: var(--space-3) var(--space-2); + margin: 0 calc(-1 * var(--space-2)); + border-bottom: 1px solid var(--color-neutral-100); + font-size: var(--text-md); + color: var(--color-neutral-800); + line-height: 1.5; + border-radius: var(--radius-sm); + transition: background-color 0.15s; +} + +.ingredientItem:hover { + background-color: var(--color-neutral-100); +} + +.ingredientItem:last-child { + border-bottom: none; +} + +.instructionGroup { + margin-bottom: var(--space-12); +} + +.instructionGroup:last-child { + margin-bottom: 0; +} + +.instructionGroupTitle { + font-family: var(--font-family-display); + font-style: italic; + font-size: var(--text-xl); + font-weight: 400; + color: var(--color-neutral-500); + margin: 0 0 var(--space-6) 0; + padding-bottom: var(--space-2); + border-bottom: 1px solid var(--color-neutral-200); +} + +.instructionsList { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: var(--space-6); +} + +.instructionItem { + display: flex; + gap: var(--space-5); +} + +.stepNumber { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: var(--radius-full); + background-color: var(--color-neutral-100); + border: 1px solid var(--color-neutral-200); + font-size: var(--text-xs); + font-weight: 700; + color: var(--color-neutral-700); + transition: + border-color 0.2s, + color 0.2s; +} + +.instructionItem:hover .stepNumber { + border-color: var(--color-neutral-500); + color: var(--color-neutral-500); +} + +.stepContent { + flex: 1; + padding-top: 4px; +} + +.stepText { + margin: 0; + font-size: var(--text-md); + line-height: 1.7; + color: var(--color-neutral-800); +} + +.notFound { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + gap: var(--space-4); + text-align: center; + padding: var(--space-8); +} + +.notFound h1 { + font-size: var(--text-2xl); + font-weight: 700; + color: var(--color-neutral-900); +} + +.notFound a { + color: var(--color-neutral-500); + text-decoration: none; + font-weight: 500; +} + +.notFound a:hover { + text-decoration: underline; +} diff --git a/src/routes/recipe/[slug].tsx b/src/routes/recipe/[slug].tsx new file mode 100644 index 0000000..efde232 --- /dev/null +++ b/src/routes/recipe/[slug].tsx @@ -0,0 +1,185 @@ +import { A, useParams } from "@solidjs/router"; +import { type Component, For, Show } from "solid-js"; +import RecipeVideo from "~/components/RecipeVideo/RecipeVideo"; +import { strings } from "~/constants/strings"; +import { getRecipeDataBySlug } from "~/utils/loadRecipes"; +import styles from "./[slug].module.css"; + +const RecipePage: Component = () => { + const params = useParams<{ slug: string }>(); + const recipe = () => getRecipeDataBySlug(params.slug); + + const formatTime = (time: number | null): string => { + if (!time) { + return "N/A"; + } + return `${time} min`; + }; + + const formatServings = (servings: number | null): string => { + if (!servings) { + return "N/A"; + } + return `${servings} servings`; + }; + + const formatIngredient = (ingredient: { + name: string; + amount: string | null; + unit: string | null; + }): string => { + if (ingredient.amount && ingredient.unit) { + return `${ingredient.amount} ${ingredient.unit} ${ingredient.name}`; + } + if (ingredient.amount) { + return `${ingredient.amount} ${ingredient.name}`; + } + return ingredient.name; + }; + + const getDifficultyClass = (difficulty: string | null): string => { + switch (difficulty) { + case "easy": + return styles.difficultyEasy; + case "medium": + return styles.difficultyMedium; + case "hard": + return styles.difficultyHard; + default: + return styles.difficultyUnknown; + } + }; + + return ( + +

{strings.recipe.notFound}

+ {strings.recipe.backToList} +
+ } + > + {(recipeData) => ( +
+ + +
+
+
+

{recipeData().title}

+

{recipeData().description}

+
+ +
+
+ {strings.recipe.difficultyLabel} + + {(() => { + const diff = recipeData().difficulty; + return diff ? diff.toUpperCase() : "UNKNOWN"; + })()} + +
+ +
+ {strings.recipe.prep} + {formatTime(recipeData().prep_time)} +
+
+
+ {strings.recipe.total} + {formatTime(recipeData().total_time)} +
+
+ {strings.recipe.servings} + {formatServings(recipeData().servings)} +
+
+ + 0 || recipeData().source_url}> +
+ 0}> +
+ {recipeData().tags.map((tag) => ( + {tag} + ))} +
+
+ + {(sourceUrl) => ( + + {strings.recipe.viewSource} + open_in_new + + )} + +
+
+
+ +
+
+

{strings.recipe.ingredients}

+
    + {recipeData().ingredients.map((ingredient) => ( +
  • + {formatIngredient(ingredient)} +
  • + ))} +
+
+ +
+

{strings.recipe.instructions}

+ + {(section) => ( +
+ 1}> +

{section.name}

+
+
    + {section.steps.map((step, index) => ( +
  1. + {index + 1} +
    +

    {step}

    +
    +
  2. + ))} +
+
+ )} +
+
+
+ + + {(videoUrl) => } + +
+
+ )} + + ); +}; + +export default RecipePage; diff --git a/src/styles/variables.css b/src/styles/variables.css index 7837ccb..0589d37 100644 --- a/src/styles/variables.css +++ b/src/styles/variables.css @@ -1,40 +1,31 @@ :root { - /* Primary: Drizzle */ - --color-primary-100: #f4fff2; - --color-primary-200: #f0feed; - --color-primary-300: #e6f5e2; - --color-primary-400: #cfdec9; - --color-primary-500: #a3b09d; - --color-primary-600: #7b8e6e; - --color-primary-700: #5b6b4d; - --color-primary-800: #3e4932; - --color-primary-900: #21261a; + /* Primary: Neutral (minimalist dark) */ + --color-primary-50: #fafafa; + --color-primary-100: #f5f5f5; + --color-primary-200: #e5e5e5; + --color-primary-300: #d4d4d4; + --color-primary-400: #a3a3a3; + --color-primary-500: #525252; + --color-primary-600: #404040; + --color-primary-700: #262626; + --color-primary-800: #171717; + --color-primary-900: #0a0a0a; - /* Accent: Galactic Highway */ - --color-accent-100: #f7f2ff; - --color-accent-200: #d1bcfe; - --color-accent-300: #a283f8; - --color-accent-400: #6a4ae6; - --color-accent-500: #3016c4; - --color-accent-600: #15079d; - --color-accent-700: #050275; - --color-accent-800: #00024e; - --color-accent-900: #000326; - - /* Neutral */ - --color-neutral-100: #fafcfa; - --color-neutral-200: #e6ebe5; - --color-neutral-300: #d3dad1; - --color-neutral-400: #c0c9be; - --color-neutral-500: #afb8ab; - --color-neutral-600: #8c9388; - --color-neutral-700: #696f65; - --color-neutral-800: #474b44; - --color-neutral-900: #242622; + /* Neutral: Gray */ + --color-neutral-50: #fafafa; + --color-neutral-100: #f5f5f5; + --color-neutral-200: #e5e5e5; + --color-neutral-300: #d4d4d4; + --color-neutral-400: #a3a3a3; + --color-neutral-500: #737373; + --color-neutral-600: #525252; + --color-neutral-700: #404040; + --color-neutral-800: #262626; + --color-neutral-900: #171717; /* Typography */ - --font-family-base: - Gordita, Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; + --font-family-display: "Playfair Display", Georgia, "Times New Roman", serif; + --font-family-base: "Inter", system-ui, -apple-system, sans-serif; /* Font sizes */ --text-xs: 0.75rem; diff --git a/src/types/Recipe.ts b/src/types/Recipe.ts index 7efe974..9a0cf3b 100644 --- a/src/types/Recipe.ts +++ b/src/types/Recipe.ts @@ -4,6 +4,11 @@ export type Ingredient = { unit: string | null; }; +export type InstructionSection = { + name: string; + steps: ReadonlyArray; +}; + export type RecipeData = { title: string; description: string; @@ -12,11 +17,12 @@ export type RecipeData = { total_time: number | null; servings: number | null; ingredients: Ingredient[]; - instructions: string[]; + instructions: ReadonlyArray; tags: string[]; difficulty: "easy" | "medium" | "hard" | null; cuisine: string | null; source_url: string | null; + video_url?: string; }; export type Recipe = { diff --git a/src/utils/loadRecipes.ts b/src/utils/loadRecipes.ts index 2798bbc..13e23d8 100644 --- a/src/utils/loadRecipes.ts +++ b/src/utils/loadRecipes.ts @@ -2,7 +2,7 @@ import type { Recipe, RecipeData } from "~/types/Recipe"; import { generateSlug } from "./generateSlug.js"; // Import all recipe JSON files using Vite's glob import -const recipeModules = import.meta.glob("../../data/sk/*.json", { +const recipeModules = import.meta.glob("../../data/*.json", { eager: true, import: "default", });