Dinner With Lucy

Featured

A self-hosted recipe and cookbook manager with fraction-accurate ingredient scaling, star ratings, and cookbook collections — built for household use.

Live Demo

See it in action

This project is deployed and available to explore right now.

Launch Live Site

Overview

About This Project

Dinner With Lucy is a self-hosted recipe manager for my household, covering recipe creation, cookbook collections, and per-user star ratings. It spans 16 routes across four areas: public browsing, authenticated user management, an admin panel for accounts and tags, and the auth flow.

Ingredient scaling

Ingredients are stored as (numerator, denominator) integer pairs rather than decimals. Ingredient::parseQuantity() accepts freeform input ("2 1/4", "1/2") and converts it to the pair. formattedAtScale(int $mult, int $div) multiplies the stored fraction, simplifies with the Euclidean GCD algorithm, then formats it back as a mixed number — so 1/3 at 3× returns exactly "1 cup" rather than "1.0 cup" or floating-point drift.

All four scale variants (½×, 1×, 2×, 3×) are pre-computed server-side and serialised into the Blade output via @js($ingredientScales). Alpine reads the correct variant with a :text binding on each ingredient. The scale toggle is entirely client-side — no server round-trip, and no Livewire re-render that would reset the ingredient checkboxes the user had ticked mid-recipe.

Cookbooks

Cookbooks are ordered collections of recipes. Attachment order is preserved via a sort_order column on the cookbook_recipe pivot, queried with orderByPivot('sort_order'). Cascade constraints on both foreign keys handle cleanup of pivot rows, images, ratings, and tags automatically when a cookbook is deleted.

Star ratings

Ratings are polymorphic (rateable_id, rateable_type) via a HasRatings trait shared between recipes and cookbooks. Clicking the same star again removes the rating — HasRatings::rate() calls updateOrCreate keyed on user and rateable. The star display has two modes: read-only renders a two-layer SVG where the filled layer is clipped to a percentage width matching the average (3.7 stars = 74% fill); interactive mode uses Alpine to track hover state with :class bindings per star, then fires wire:click to persist.

Tags

58 built-in theme tags are seeded from DefaultThemeTags::TAGS with colours defined as CSS variable pairs. Their colours are locked in the admin UI — DefaultThemeTags::isDefault($slug) blocks the colour picker for them. Admin-created custom tags store their colour as inline style attributes. Tags attach via a polymorphic many-to-many; the live-search input in editors calls Tag::firstOrCreate() so typing a new name creates it on save.

Livewire compiler bug

Livewire 4.2.2's component detector uses a greedy regex with the s (dotall) flag to check for class-based components. It spans past the closing PHP tag into the Blade template, so any Volt component whose HTML contained the word "new" — a "new cookbook" button, a "new recipe" link — was misidentified as a class-based component and broke the page. The fix, PatchedLivewireFinder in app/Support/, extracts just the PHP block with a separate regex first, then runs the class-detection check on that substring only. It's swapped into the container in AppServiceProvider.

Theming

Dark and light modes use CSS custom properties rather than Tailwind's dark: variant. The theme is applied via a data-theme attribute on <html>; a [data-theme="dark"] block in app.css reassigns the full set of CSS custom properties. The toggle is a plain Alpine button in the nav that writes to localStorage under the key dwl-theme and updates the attribute directly with document.documentElement.setAttribute('data-theme', theme) — no server involvement. An inline <script> in <head> reads localStorage and sets the attribute before first paint, and re-runs on livewire:navigated to survive Livewire's SPA-style navigation. First-time visitors get the light theme by default — there's no prefers-color-scheme detection.

Architecture

Every page is a Livewire Volt functional component — state(), mount(), computed(), and action() declarations in a single .blade.php file, routed via Volt::route(). No traditional controllers. Business logic touching the filesystem lives in ImageService; cross-model behaviours are in HasImages and HasRatings traits.

Explore

More Projects

All projects
Screenshots
Featured

Bridge

A personal operations dashboard managing chores, fitness, plants, and job applications. Deployed on a home server.

PHP Laravel Livewire MySQL +3 more
View details