sandwitches 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ Metadata-Version: 2.3
2
+ Name: sandwitches
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author: Martyn van Dijke
6
+ Author-email: Martyn van Dijke <martijnvdijke600@gmail.com>
7
+ Requires-Dist: django-debug-toolbar>=6.1.0
8
+ Requires-Dist: django-filter>=25.2
9
+ Requires-Dist: django-simple-history>=3.10.1
10
+ Requires-Dist: django>=5.2.7
11
+ Requires-Dist: djangorestframework>=3.16.1
12
+ Requires-Dist: gunicorn>=23.0.0
13
+ Requires-Dist: markdown>=3.10
14
+ Requires-Dist: pillow>=12.0.0
15
+ Requires-Dist: uwsgi>=2.0.31
16
+ Requires-Python: >=3.12
17
+ Description-Content-Type: text/markdown
18
+
19
+ # broodjes.vandijke.xyz
20
+
21
+ Website files for broodjes.vandijke.xyz
@@ -0,0 +1,3 @@
1
+ # broodjes.vandijke.xyz
2
+
3
+ Website files for broodjes.vandijke.xyz
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "sandwitches"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Martyn van Dijke", email = "martijnvdijke600@gmail.com" }
8
+ ]
9
+
10
+ requires-python = ">=3.12"
11
+
12
+ dependencies = [
13
+ "django-debug-toolbar>=6.1.0",
14
+ "django-filter>=25.2",
15
+ "django-simple-history>=3.10.1",
16
+ "django>=5.2.7",
17
+ "djangorestframework>=3.16.1",
18
+ "gunicorn>=23.0.0",
19
+ "markdown>=3.10",
20
+ "pillow>=12.0.0",
21
+ "uwsgi>=2.0.31",
22
+ ]
23
+
24
+
25
+ [build-system]
26
+ requires = ["uv_build>=0.8.19,<0.9.0"]
27
+ build-backend = "uv_build"
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "invoke>=2.2.1",
32
+ "pytest-django>=4.11.1",
33
+ "pytest>=8.4.2",
34
+ "ruff>=0.14.3",
35
+ "ty>=0.0.1a26",
36
+ ]
37
+
38
+ [tool.uv]
39
+ package = true
40
+
41
+ [tool.pytest.ini_options]
42
+ DJANGO_SETTINGS_MODULE = "sandwitches.settings"
File without changes
@@ -0,0 +1,6 @@
1
+ from django.contrib import admin
2
+ from .models import Recipe, Tag
3
+
4
+
5
+ admin.site.register(Recipe)
6
+ admin.site.register(Tag)
@@ -0,0 +1,16 @@
1
+ """
2
+ ASGI config for sandwitches project.
3
+
4
+ It exposes the ASGI callable as a module-level variable named ``application``.
5
+
6
+ For more information on this file, see
7
+ https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
8
+ """
9
+
10
+ import os
11
+
12
+ from django.core.asgi import get_asgi_application
13
+
14
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sandwitches.settings")
15
+
16
+ application = get_asgi_application()
@@ -0,0 +1,60 @@
1
+ from django import forms
2
+ from django.contrib.auth import get_user_model
3
+ from .models import Recipe
4
+
5
+ User = get_user_model()
6
+
7
+
8
+ class AdminSetupForm(forms.Form):
9
+ username = forms.CharField(max_length=150, label="Username")
10
+ email = forms.EmailField(required=False, label="Email (optional)")
11
+ password1 = forms.CharField(label="Password", widget=forms.PasswordInput)
12
+ password2 = forms.CharField(label="Confirm password", widget=forms.PasswordInput)
13
+ first_name = forms.CharField(max_length=30, required=False, label="First name")
14
+ last_name = forms.CharField(max_length=150, required=False, label="Last name")
15
+
16
+ def clean_username(self):
17
+ username = self.cleaned_data.get("username")
18
+ if User.objects.filter(username=username).exists():
19
+ raise forms.ValidationError("A user with that username already exists.")
20
+ return username
21
+
22
+ def clean(self):
23
+ cleaned = super().clean()
24
+ p1 = cleaned.get("password1")
25
+ p2 = cleaned.get("password2")
26
+ if p1 and p2 and p1 != p2:
27
+ raise forms.ValidationError("Passwords do not match.")
28
+ return cleaned
29
+
30
+ def save(self):
31
+ data = self.cleaned_data
32
+ user = User.objects.create_superuser(
33
+ username=data["username"],
34
+ email=data.get("email") or "",
35
+ password=data["password1"],
36
+ )
37
+ # set optional names
38
+ if data.get("first_name"):
39
+ user.first_name = data["first_name"]
40
+ if data.get("last_name"):
41
+ user.last_name = data["last_name"]
42
+ user.is_staff = True
43
+ user.save()
44
+ return user
45
+
46
+
47
+ class RecipeForm(forms.ModelForm):
48
+ class Meta:
49
+ model = Recipe
50
+ fields = [
51
+ "title",
52
+ "description",
53
+ "ingredients",
54
+ "instructions",
55
+ "image",
56
+ "tags",
57
+ ]
58
+ widgets = {
59
+ "tags": forms.TextInput(attrs={"placeholder": "tag1,tag2"}),
60
+ }
@@ -0,0 +1,75 @@
1
+ # Generated by Django 5.2.8 on 2025-11-05 19:15
2
+
3
+ import sandwitches.storage
4
+ from django.db import migrations, models
5
+
6
+
7
+ class Migration(migrations.Migration):
8
+ initial = True
9
+
10
+ dependencies = []
11
+
12
+ operations = [
13
+ migrations.CreateModel(
14
+ name="Tag",
15
+ fields=[
16
+ (
17
+ "id",
18
+ models.BigAutoField(
19
+ auto_created=True,
20
+ primary_key=True,
21
+ serialize=False,
22
+ verbose_name="ID",
23
+ ),
24
+ ),
25
+ ("name", models.CharField(max_length=50, unique=True)),
26
+ ("slug", models.SlugField(blank=True, max_length=60, unique=True)),
27
+ ],
28
+ options={
29
+ "verbose_name": "Tag",
30
+ "verbose_name_plural": "Tags",
31
+ "ordering": ("name",),
32
+ },
33
+ ),
34
+ migrations.CreateModel(
35
+ name="Recipe",
36
+ fields=[
37
+ (
38
+ "id",
39
+ models.BigAutoField(
40
+ auto_created=True,
41
+ primary_key=True,
42
+ serialize=False,
43
+ verbose_name="ID",
44
+ ),
45
+ ),
46
+ ("title", models.CharField(max_length=255, unique=True)),
47
+ ("slug", models.SlugField(blank=True, max_length=255, unique=True)),
48
+ ("description", models.TextField(blank=True)),
49
+ ("ingredients", models.TextField(blank=True)),
50
+ ("instructions", models.TextField(blank=True)),
51
+ (
52
+ "image",
53
+ models.ImageField(
54
+ blank=True,
55
+ null=True,
56
+ storage=sandwitches.storage.HashedFilenameStorage(),
57
+ upload_to="recipes/",
58
+ ),
59
+ ),
60
+ ("created_at", models.DateTimeField(auto_now_add=True)),
61
+ ("updated_at", models.DateTimeField(auto_now=True)),
62
+ (
63
+ "tags",
64
+ models.ManyToManyField(
65
+ blank=True, related_name="recipes", to="sandwitches.tag"
66
+ ),
67
+ ),
68
+ ],
69
+ options={
70
+ "verbose_name": "Recipe",
71
+ "verbose_name_plural": "Recipes",
72
+ "ordering": ("-created_at",),
73
+ },
74
+ ),
75
+ ]
@@ -0,0 +1,61 @@
1
+ # Generated by Django 5.2.8 on 2025-11-10 18:55
2
+
3
+ import django.db.models.deletion
4
+ import simple_history.models
5
+ from django.conf import settings
6
+ from django.db import migrations, models
7
+
8
+
9
+ class Migration(migrations.Migration):
10
+ dependencies = [
11
+ ("sandwitches", "0001_initial"),
12
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
13
+ ]
14
+
15
+ operations = [
16
+ migrations.CreateModel(
17
+ name="HistoricalRecipe",
18
+ fields=[
19
+ (
20
+ "id",
21
+ models.BigIntegerField(
22
+ auto_created=True, blank=True, db_index=True, verbose_name="ID"
23
+ ),
24
+ ),
25
+ ("title", models.CharField(db_index=True, max_length=255)),
26
+ ("slug", models.SlugField(blank=True, max_length=255)),
27
+ ("description", models.TextField(blank=True)),
28
+ ("ingredients", models.TextField(blank=True)),
29
+ ("instructions", models.TextField(blank=True)),
30
+ ("image", models.TextField(blank=True, max_length=100, null=True)),
31
+ ("created_at", models.DateTimeField(blank=True, editable=False)),
32
+ ("updated_at", models.DateTimeField(blank=True, editable=False)),
33
+ ("history_id", models.AutoField(primary_key=True, serialize=False)),
34
+ ("history_date", models.DateTimeField(db_index=True)),
35
+ ("history_change_reason", models.CharField(max_length=100, null=True)),
36
+ (
37
+ "history_type",
38
+ models.CharField(
39
+ choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")],
40
+ max_length=1,
41
+ ),
42
+ ),
43
+ (
44
+ "history_user",
45
+ models.ForeignKey(
46
+ null=True,
47
+ on_delete=django.db.models.deletion.SET_NULL,
48
+ related_name="+",
49
+ to=settings.AUTH_USER_MODEL,
50
+ ),
51
+ ),
52
+ ],
53
+ options={
54
+ "verbose_name": "historical Recipe",
55
+ "verbose_name_plural": "historical Recipes",
56
+ "ordering": ("-history_date", "-history_id"),
57
+ "get_latest_by": ("history_date", "history_id"),
58
+ },
59
+ bases=(simple_history.models.HistoricalChanges, models.Model),
60
+ ),
61
+ ]
@@ -0,0 +1,94 @@
1
+ from django.db import models
2
+ from django.urls import reverse
3
+ from django.utils.text import slugify
4
+ from .storage import HashedFilenameStorage
5
+ from simple_history.models import HistoricalRecords
6
+
7
+ hashed_storage = HashedFilenameStorage()
8
+
9
+
10
+ class Tag(models.Model):
11
+ name = models.CharField(max_length=50, unique=True)
12
+ slug = models.SlugField(max_length=60, unique=True, blank=True)
13
+
14
+ class Meta:
15
+ ordering = ("name",)
16
+ verbose_name = "Tag"
17
+ verbose_name_plural = "Tags"
18
+
19
+ def save(self, *args, **kwargs):
20
+ if not self.slug:
21
+ base = slugify(self.name)[:55]
22
+ slug = base
23
+ n = 1
24
+ while Tag.objects.filter(slug=slug).exclude(pk=self.pk).exists():
25
+ slug = f"{base}-{n}"
26
+ n += 1
27
+ self.slug = slug
28
+ super().save(*args, **kwargs)
29
+
30
+ def __str__(self):
31
+ return self.name
32
+
33
+
34
+ class Recipe(models.Model):
35
+ title = models.CharField(max_length=255, unique=True)
36
+ slug = models.SlugField(max_length=255, unique=True, blank=True)
37
+ description = models.TextField(blank=True)
38
+ ingredients = models.TextField(blank=True)
39
+ instructions = models.TextField(blank=True)
40
+ image = models.ImageField(
41
+ upload_to="recipes/", # storage will replace with hashed path
42
+ storage=hashed_storage,
43
+ blank=True,
44
+ null=True,
45
+ )
46
+
47
+ # ManyToMany: tags are reusable and shared between recipes
48
+ tags = models.ManyToManyField(Tag, blank=True, related_name="recipes")
49
+
50
+ created_at = models.DateTimeField(auto_now_add=True)
51
+ updated_at = models.DateTimeField(auto_now=True)
52
+ history = HistoricalRecords()
53
+
54
+ class Meta:
55
+ ordering = ("-created_at",)
56
+ verbose_name = "Recipe"
57
+ verbose_name_plural = "Recipes"
58
+
59
+ def save(self, *args, **kwargs):
60
+ if not self.slug:
61
+ base = slugify(self.title)[:240]
62
+ slug = base
63
+ n = 1
64
+ while Recipe.objects.filter(slug=slug).exclude(pk=self.pk).exists():
65
+ slug = f"{base}-{n}"
66
+ n += 1
67
+ self.slug = slug
68
+ super().save(*args, **kwargs)
69
+
70
+ def tag_list(self):
71
+ # returns list of tag names
72
+ return list(self.tags.values_list("name", flat=True))
73
+
74
+ def set_tags_from_string(self, tag_string):
75
+ """
76
+ Accepts a comma separated string like "tag1, tag2" and attaches existing tags
77
+ or creates new ones as needed. Returns the Tag queryset assigned.
78
+ """
79
+ names = [t.strip() for t in (tag_string or "").split(",") if t.strip()]
80
+ tags = []
81
+ for name in names:
82
+ tag = Tag.objects.filter(name__iexact=name).first()
83
+ if not tag:
84
+ tag = Tag.objects.create(name=name)
85
+ tags.append(tag)
86
+ # replace existing tags with these
87
+ self.tags.set(tags)
88
+ return self.tags.all()
89
+
90
+ def get_absolute_url(self):
91
+ return reverse("recipe_detail", kwargs={"pk": self.pk, "slug": self.slug})
92
+
93
+ def __str__(self):
94
+ return self.title
@@ -0,0 +1,134 @@
1
+ """
2
+ Django settings for sandwitches project.
3
+
4
+ Generated by 'django-admin startproject' using Django 5.2.7.
5
+
6
+ For more information on this file, see
7
+ https://docs.djangoproject.com/en/5.2/topics/settings/
8
+
9
+ For the full list of settings and their values, see
10
+ https://docs.djangoproject.com/en/5.2/ref/settings/
11
+ """
12
+
13
+ from pathlib import Path
14
+ import os
15
+
16
+
17
+ SECRET_KEY = os.environ.get("SECRET_KEY")
18
+ DEBUG = bool(os.environ.get("DEBUG", default=0))
19
+ ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "127.0.0.1").split(",")
20
+ CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",")
21
+
22
+ # Build paths inside the project like this: BASE_DIR / 'subdir'.
23
+ BASE_DIR = Path(__file__).resolve().parent.parent
24
+
25
+ # Application definition
26
+
27
+ INSTALLED_APPS = [
28
+ "django.contrib.admin",
29
+ "django.contrib.auth",
30
+ "django.contrib.contenttypes",
31
+ "django.contrib.sessions",
32
+ "django.contrib.messages",
33
+ "django.contrib.staticfiles",
34
+ "sandwitches",
35
+ "debug_toolbar",
36
+ "simple_history",
37
+ ]
38
+
39
+ MIDDLEWARE = [
40
+ "django.middleware.security.SecurityMiddleware",
41
+ "django.contrib.sessions.middleware.SessionMiddleware",
42
+ "django.middleware.common.CommonMiddleware",
43
+ "django.middleware.csrf.CsrfViewMiddleware",
44
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
45
+ "django.contrib.messages.middleware.MessageMiddleware",
46
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
47
+ "debug_toolbar.middleware.DebugToolbarMiddleware",
48
+ "simple_history.middleware.HistoryRequestMiddleware",
49
+ ]
50
+
51
+ ROOT_URLCONF = "sandwitches.urls"
52
+
53
+ TEMPLATES = [
54
+ {
55
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
56
+ "DIRS": [],
57
+ "APP_DIRS": True,
58
+ "OPTIONS": {
59
+ "context_processors": [
60
+ "django.template.context_processors.request",
61
+ "django.contrib.auth.context_processors.auth",
62
+ "django.contrib.messages.context_processors.messages",
63
+ "django.template.context_processors.csrf",
64
+ ],
65
+ },
66
+ },
67
+ ]
68
+
69
+ WSGI_APPLICATION = "sandwitches.wsgi.application"
70
+
71
+
72
+ # Database
73
+ # https://docs.djangoproject.com/en/5.2/ref/settings/#databases
74
+
75
+ DATABASES = {
76
+ "default": {
77
+ "ENGINE": "django.db.backends.sqlite3",
78
+ "NAME": Path("/config/db.sqlite3"),
79
+ }
80
+ }
81
+
82
+
83
+ # Password validation
84
+ # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
85
+
86
+ AUTH_PASSWORD_VALIDATORS = [
87
+ {
88
+ "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
89
+ },
90
+ {
91
+ "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
92
+ },
93
+ {
94
+ "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
95
+ },
96
+ {
97
+ "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
98
+ },
99
+ ]
100
+
101
+
102
+ # Media files (for uploaded images)
103
+ MEDIA_URL = "/media/"
104
+ MEDIA_ROOT = Path("/config/media")
105
+
106
+ # Static (for CSS etc)
107
+ STATIC_URL = "/static/"
108
+ STATIC_ROOT = Path("/config/staticfiles")
109
+
110
+ # Internationalization
111
+ # https://docs.djangoproject.com/en/5.2/topics/i18n/
112
+
113
+ LANGUAGE_CODE = "en-us"
114
+
115
+ TIME_ZONE = "UTC"
116
+
117
+ USE_I18N = True
118
+
119
+ USE_TZ = True
120
+
121
+ INTERNAL_IPS = [
122
+ "127.0.0.1",
123
+ ]
124
+
125
+
126
+ # Static files (CSS, JavaScript, Images)
127
+ # https://docs.djangoproject.com/en/5.2/howto/static-files/
128
+
129
+ STATIC_URL = "static/"
130
+
131
+ # Default primary key field type
132
+ # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
133
+
134
+ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
@@ -0,0 +1,29 @@
1
+ import hashlib
2
+ import os
3
+ from django.core.files.base import ContentFile
4
+ from django.core.files.storage import FileSystemStorage
5
+
6
+
7
+ class HashedFilenameStorage(FileSystemStorage):
8
+ """
9
+ Save uploaded files under a hash of their contents + original extension.
10
+ Example output: media/recipes/3f8a9d...png
11
+ """
12
+
13
+ def _save(self, name, content):
14
+ # ensure we read bytes
15
+ try:
16
+ content.seek(0)
17
+ except Exception:
18
+ pass
19
+
20
+ data = content.read()
21
+ # compute hash (use first 32 hex chars to keep names shorter)
22
+ h = hashlib.sha256(data).hexdigest()[:32]
23
+ ext = os.path.splitext(name)[1].lower() or ""
24
+ # store all recipe images under recipes/
25
+ name = f"recipes/{h}{ext}"
26
+
27
+ # wrap bytes into a ContentFile so Django storage works consistently
28
+ content = ContentFile(data)
29
+ return super()._save(name, content)
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1.0" />
6
+ <title>{% block title %}Sandwitches{% endblock %}</title>
7
+ {% block extra_head %}{% endblock %}
8
+ </head>
9
+ <body class="{% block body_class %}{% endblock %}">
10
+ {% block navbar %}{% endblock %}
11
+ <main class="container" role="main">{% block content %}{% endblock %}</main>
12
+ {% block extra_scripts %}{% endblock %}
13
+ </body>
14
+ </html>
@@ -0,0 +1,244 @@
1
+ {% extends "base.html" %} {% block extra_head %}
2
+ <!-- Pico.css (CDN) -->
3
+ <link
4
+ rel="stylesheet"
5
+ href="https://unpkg.com/@picocss/pico@1.*/css/pico.min.css"
6
+ />
7
+ <style>
8
+ :root {
9
+ --card-radius: 12px;
10
+ --card-shadow-sm: 0 6px 18px rgba(15, 23, 42, 0.06);
11
+ --card-shadow-lg: 0 18px 40px rgba(15, 23, 42, 0.12);
12
+ --accent: #ff7a18;
13
+ }
14
+
15
+ /* responsive grid for cards */
16
+ section.grid {
17
+ display: grid;
18
+ gap: 1.25rem;
19
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
20
+ align-items: start;
21
+ }
22
+
23
+ /* unified card */
24
+ article.card {
25
+ background: var(--pico-card-background);
26
+ border-radius: var(--card-radius);
27
+ overflow: hidden;
28
+ padding: 0.12rem 0.45rem;
29
+ box-shadow: var(--card-shadow-sm);
30
+ transition: transform 0.18s ease, box-shadow 0.18s ease;
31
+ display: flex;
32
+ flex-direction: column;
33
+ border: 1px solid rgba(0, 0, 0, 0.04);
34
+ }
35
+ article.card:hover {
36
+ transform: translateY(-8px);
37
+ box-shadow: var(--card-shadow-lg);
38
+ }
39
+
40
+ /* image area on top */
41
+ .recipe-figure {
42
+ width: 100%;
43
+ display: block;
44
+ position: relative;
45
+ background: #f6f6f6;
46
+ flex: 0 0 auto;
47
+ }
48
+ .recipe-figure img {
49
+ width: 100%;
50
+ height: auto;
51
+ object-fit: cover;
52
+ display: block;
53
+ }
54
+
55
+ /* card body below image */
56
+ .card-body {
57
+ padding: 0.05rem 0.05rem;
58
+ display: flex;
59
+ flex-direction: column;
60
+ gap: 0.4rem;
61
+ min-height: 64px;
62
+ flex: 1 1 auto;
63
+ }
64
+
65
+ .card-title,
66
+ article header {
67
+ margin: 0;
68
+ font-size: 1.02rem;
69
+ line-height: 1.2;
70
+ font-weight: 600;
71
+ color: inherit;
72
+ word-break: break-word;
73
+ }
74
+
75
+ /* compact chips */
76
+ .chip,
77
+ .tag {
78
+ display: inline-block;
79
+ padding: 0.12rem 0.45rem;
80
+ margin: 0 0.35rem 0.35rem 0;
81
+ border-radius: 999px;
82
+ font-size: 0.78rem;
83
+ line-height: 1;
84
+ white-space: nowrap;
85
+ overflow: hidden;
86
+ text-overflow: ellipsis;
87
+ max-width: 9.5rem;
88
+ vertical-align: middle;
89
+ border: 1px solid rgba(0, 0, 0, 0.06);
90
+ background: rgba(0, 0, 0, 0.03);
91
+ color: rgba(0, 0, 0, 0.85);
92
+ }
93
+ .chip--accent {
94
+ background: rgba(255, 122, 24, 0.09);
95
+ color: var(--accent);
96
+ border-color: rgba(255, 122, 24, 0.12);
97
+ }
98
+ .tags-row {
99
+ display: flex;
100
+ flex-wrap: wrap;
101
+ gap: 0.15rem;
102
+ align-items: center;
103
+ }
104
+
105
+ footer.card-footer,
106
+ article.card footer {
107
+ padding: 0.5rem 0.9rem;
108
+ border-top: 1px solid rgba(0, 0, 0, 0.04);
109
+ background: rgba(0, 0, 0, 0.01);
110
+ display: flex;
111
+ justify-content: flex-start;
112
+ gap: 1rem;
113
+ flex: 0 0 auto;
114
+ }
115
+
116
+ footer a {
117
+ font-weight: 600;
118
+ }
119
+
120
+ @media (min-width: 900px) {
121
+ .recipe-figure img {
122
+ height: 200px;
123
+ }
124
+ }
125
+ @media (min-width: 1200px) {
126
+ .recipe-figure img {
127
+ height: 220px;
128
+ }
129
+ }
130
+ </style>
131
+ {% endblock %} {% block navbar %}
132
+ <header class="container">
133
+ <nav>
134
+ <ul>
135
+ <li>
136
+ <a href="{% url 'index' %}"><strong>Sandwitches</strong></a>
137
+ </li>
138
+ </ul>
139
+ <ul class="icons">
140
+ <li>
141
+ <a
142
+ rel="noopener noreferrer"
143
+ class="contrast"
144
+ aria-label="GitHub repository"
145
+ href="https://github.com/martynvdijke/sandwitches"
146
+ target="_blank"
147
+ ><svg
148
+ xmlns="http://www.w3.org/2000/svg"
149
+ height="24"
150
+ width="24.25"
151
+ viewBox="0 0 496 512"
152
+ class="icon-github"
153
+ >
154
+ <path
155
+ d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"
156
+ ></path></svg
157
+ ></a>
158
+ </li>
159
+ <li>
160
+ <a
161
+ href="#"
162
+ id="theme-toggle"
163
+ aria-label="Toggle theme"
164
+ role="button"
165
+ title="Toggle theme"
166
+ >☀️</a
167
+ >
168
+ </li>
169
+ </ul>
170
+ <ul>
171
+ {% if user.is_authenticated %}
172
+ <li><a href="{% url 'admin:index' %}">Admin</a></li>
173
+ <li><a href="{% url 'admin:logout' %}">Logout</a></li>
174
+ {% else %}
175
+ <li><a href="{% url 'admin:login' %}">Login</a></li>
176
+ {% endif %}
177
+ </ul>
178
+ </nav>
179
+ </header>
180
+
181
+ <script>
182
+ // Simple toggle-style theme switcher (stores choice in localStorage)
183
+ const themeSwitcher = {
184
+ localStorageKey: "picoPreferredColorScheme",
185
+ buttonSelector: "#theme-toggle",
186
+ rootAttribute: "data-theme",
187
+ init() {
188
+ // read saved choice, fallback to system preference
189
+ const saved = window.localStorage?.getItem(this.localStorageKey);
190
+ this._scheme =
191
+ saved ??
192
+ (window.matchMedia &&
193
+ window.matchMedia("(prefers-color-scheme: dark)").matches
194
+ ? "dark"
195
+ : "light");
196
+ this.applyScheme();
197
+ this.initButton();
198
+ },
199
+ initButton() {
200
+ const btn = document.querySelector(this.buttonSelector);
201
+ if (!btn) return;
202
+ this.updateButton(btn);
203
+ btn.addEventListener("click", (e) => {
204
+ e.preventDefault();
205
+ this.toggle();
206
+ this.updateButton(btn);
207
+ });
208
+ // react to storage changes from other tabs
209
+ window.addEventListener("storage", (ev) => {
210
+ if (ev.key === this.localStorageKey) {
211
+ this._scheme =
212
+ ev.newValue ||
213
+ (window.matchMedia &&
214
+ window.matchMedia("(prefers-color-scheme: dark)").matches
215
+ ? "dark"
216
+ : "light");
217
+ this.applyScheme();
218
+ this.updateButton(document.querySelector(this.buttonSelector));
219
+ }
220
+ });
221
+ },
222
+ toggle() {
223
+ this._scheme = this._scheme === "dark" ? "light" : "dark";
224
+ this.applyScheme();
225
+ window.localStorage?.setItem(this.localStorageKey, this._scheme);
226
+ },
227
+ applyScheme() {
228
+ document.documentElement?.setAttribute(this.rootAttribute, this._scheme);
229
+ },
230
+ updateButton(btn) {
231
+ if (!btn) return;
232
+ btn.textContent = this._scheme === "dark" ? "🌙" : "☀️";
233
+ btn.title =
234
+ this._scheme === "dark" ? "Switch to light" : "Switch to dark";
235
+ btn.setAttribute(
236
+ "aria-pressed",
237
+ this._scheme === "dark" ? "true" : "false"
238
+ );
239
+ },
240
+ };
241
+ themeSwitcher.init();
242
+ </script>
243
+ {% endblock %} {% block extra_scripts %} {% block page_scripts %}{% endblock %}
244
+ {% endblock %}
@@ -0,0 +1,44 @@
1
+ {% extends "base_pico.html" %}
2
+ {% block title %}{{ recipe.title }} — Sandwitch{% endblock %}
3
+
4
+ {% block content %}
5
+
6
+ {% load markdown_extras %}
7
+ <nav aria-label="breadcrumb" class="container">
8
+ <a href="{% url 'index' %}">&larr; Back to all</a>
9
+ </nav>
10
+
11
+ <div class="grid">
12
+ <article class="card">
13
+ <figure>
14
+ {% if recipe.image %}
15
+ <img src="{{ recipe.image.url }}" alt="{{ recipe.title }}">
16
+ {% endif %}
17
+ </figure>
18
+ <header>
19
+ {{ recipe.title }}
20
+ </header>
21
+ <h4>Description</h4>
22
+ {{ recipe.description|convert_markdown|safe|default:"No description yet." }}
23
+
24
+ <h4>Ingredients</h4>
25
+ {{ recipe.ingredients|convert_markdown|safe|default:"No ingredients listed." }}
26
+
27
+ <h4>Instructions</h4>
28
+ {{ recipe.instructions|convert_markdown|safe|default:"No instructions yet." }}
29
+
30
+ <div class="tags-row">
31
+ <div style="margin-top:12px;">
32
+ {% for tag in recipe.tags.all %}
33
+ <span class="tag">{{ tag.name }}</span>
34
+ {% endfor %}
35
+ </div>
36
+ </div>
37
+ {% if user.is_authenticated and user.is_staff %}
38
+ <footer>
39
+ <a href="/admin/sandwitches/recipe/{{ recipe.pk }}/change/">Edit</a>
40
+ </footer>
41
+ {% endif %}
42
+ </article>
43
+ </div>
44
+ {% endblock %}
@@ -0,0 +1,15 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <title>Edit</title>
6
+ </head>
7
+ <body>
8
+ <h1>Edit Recipe: {{ recipe.title }}</h1>
9
+ <form method="post" enctype="multipart/form-data">
10
+ {% csrf_token %} {{ form.as_p }}
11
+ <button type="submit">Save</button>
12
+ <a href="{% url 'recipes:admin_list' %}">Cancel</a>
13
+ </form>
14
+ </body>
15
+ </html>
@@ -0,0 +1,71 @@
1
+ {% extends "base_pico.html" %}
2
+
3
+ {% block title %}Sandwitches{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="grid search-row">
7
+ <div>
8
+ <h4>Sandwitches: sandwiches so good, they haunt you!</h4>
9
+ </div>
10
+ <div>
11
+ <form role="search" onsubmit="return false;">
12
+ <input id="search" type="search" placeholder="Search by title or tag" aria-label="Search">
13
+ </form>
14
+ </div>
15
+ </div>
16
+
17
+ <section class="grid">
18
+ {% for recipe in recipes %}
19
+ <article class="card" onclick="location.href='{% url 'recipe_detail' recipe.slug %}';" style="cursor:pointer;">
20
+ {% if recipe.image %}
21
+ <figure class="recipe-figure">
22
+ <img src="{{ recipe.image.url }}" alt="{{ recipe.title }}">
23
+ </figure>
24
+ {% endif %}
25
+
26
+ <div class="card-body">
27
+ <header class="card-title">{{ recipe.title }}</header>
28
+
29
+ <div class="tags-row" aria-hidden="false">
30
+ {% for tag in recipe.tags.all %}
31
+ <span class="chip{% if tag|length < 8 %} chip--accent{% endif %}">{{ tag.name }}</span>
32
+ {% endfor %}
33
+ </div>
34
+ </div>
35
+
36
+ {% if user.is_authenticated and user.is_staff %}
37
+ <footer>
38
+ <a href="/admin/sandwitches/recipe/{{ recipe.pk }}/change/" rel="noopener">Edit</a>
39
+ </footer>
40
+ {% endif %}
41
+ </article>
42
+
43
+ {% empty %}
44
+ <article class="card">
45
+ <div class="card-body">
46
+ <p>No sandwitches yet, please stay tuned.</p>
47
+ </div>
48
+ </article>
49
+ {% endfor %}
50
+ </section>
51
+
52
+ {% endblock %}
53
+
54
+ {% block page_scripts %}
55
+ <script>
56
+ document.addEventListener('DOMContentLoaded', function() {
57
+ const search = document.getElementById('search');
58
+ if (!search) return;
59
+ search.addEventListener('input', function() {
60
+ const q = this.value.toLowerCase().trim();
61
+ document.querySelectorAll('section.grid article.card').forEach(card => {
62
+ const title = (card.querySelector('.card-title')?.textContent || '').toLowerCase();
63
+ const tags = Array.from(card.querySelectorAll('.tag, .chip')).map(t => t.textContent.toLowerCase());
64
+ const descr = (card.querySelector('.card-body p')?.textContent || '').toLowerCase();
65
+ const match = !q || title.includes(q) || descr.includes(q) || tags.some(t => t.includes(q));
66
+ card.style.display = match ? '' : 'none';
67
+ });
68
+ });
69
+ });
70
+ </script>
71
+ {% endblock %}
@@ -0,0 +1,53 @@
1
+ {% extends "base_pico.html" %}
2
+ {% load static %}
3
+ {% block title %}Initial setup — Create admin{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="container" style="max-width:720px; margin:2rem auto;">
7
+ <article class="card">
8
+ <div class="card-body">
9
+ <h2>Create initial administrator</h2>
10
+ <p>
11
+ This page is only available when there are no admin users in the database.
12
+ After creating the account you will be logged in and redirected to the admin.
13
+ </p>
14
+
15
+ <form method="post" novalidate>
16
+ {% csrf_token %}
17
+ {% if form.non_field_errors %}
18
+ <div class="card-panel" role="alert">
19
+ <ul>
20
+ {% for err in form.non_field_errors %}
21
+ <li>{{ err }}</li>
22
+ {% endfor %}
23
+ </ul>
24
+ </div>
25
+ {% endif %}
26
+
27
+ <label for="{{ form.username.id_for_label }}">Username</label>
28
+ {{ form.username }}
29
+
30
+ <label for="{{ form.email.id_for_label }}">Email (optional)</label>
31
+ {{ form.email }}
32
+
33
+ <label for="{{ form.first_name.id_for_label }}">First name</label>
34
+ {{ form.first_name }}
35
+
36
+ <label for="{{ form.last_name.id_for_label }}">Last name</label>
37
+ {{ form.last_name }}
38
+
39
+ <label for="{{ form.password1.id_for_label }}">Password</label>
40
+ {{ form.password1 }}
41
+
42
+ <label for="{{ form.password2.id_for_label }}">Confirm password</label>
43
+ {{ form.password2 }}
44
+
45
+ <p style="margin-top:1rem;">
46
+ <button type="submit">Create admin</button>
47
+ <a class="contrast" href="{% url 'index' %}">Cancel</a>
48
+ </p>
49
+ </form>
50
+ </div>
51
+ </article>
52
+ </div>
53
+ {% endblock %}
@@ -0,0 +1,17 @@
1
+ import markdown
2
+
3
+ from django import template
4
+ from django.template.defaultfilters import stringfilter
5
+
6
+ register = template.Library()
7
+
8
+
9
+ @register.filter
10
+ @stringfilter
11
+ def convert_markdown(value):
12
+ md = markdown.markdown(
13
+ value,
14
+ extensions=["markdown.extensions.fenced_code", "markdown.extensions.tables"],
15
+ )
16
+
17
+ return md
@@ -0,0 +1,45 @@
1
+ """
2
+ URL configuration for sandwitches project.
3
+
4
+ The `urlpatterns` list routes URLs to views. For more information please see:
5
+ https://docs.djangoproject.com/en/5.2/topics/http/urls/
6
+ Examples:
7
+ Function views
8
+ 1. Add an import: from my_app import views
9
+ 2. Add a URL to urlpatterns: path('', views.home, name='home')
10
+ Class-based views
11
+ 1. Add an import: from other_app.views import Home
12
+ 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
13
+ Including another URLconf
14
+ 1. Import the include() function: from django.urls import include, path
15
+ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
16
+ """
17
+
18
+ from django.contrib import admin
19
+ from django.urls import path
20
+ from . import views
21
+
22
+ from django.conf import settings
23
+ from django.conf.urls.static import static
24
+ from debug_toolbar.toolbar import debug_toolbar_urls
25
+ import os
26
+ import sys
27
+
28
+
29
+ urlpatterns = [
30
+ path("", views.index, name="index"),
31
+ path("admin/", admin.site.urls),
32
+ path("recipes/<slug:slug>/", views.recipe_detail, name="recipe_detail"),
33
+ path("setup/", views.setup, name="setup"),
34
+ ]
35
+
36
+ if "test" not in sys.argv or "PYTEST_VERSION" in os.environ:
37
+ from debug_toolbar.toolbar import debug_toolbar_urls
38
+
39
+ urlpatterns = [
40
+ *urlpatterns,
41
+ ] + debug_toolbar_urls()
42
+
43
+
44
+ if settings.DEBUG:
45
+ urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
@@ -0,0 +1,58 @@
1
+ from django.shortcuts import render, get_object_or_404, redirect
2
+ from django.urls import reverse
3
+ from django.contrib import messages
4
+ from django.contrib.auth import login
5
+ from django.contrib.auth import get_user_model
6
+
7
+ from .models import Recipe
8
+ from .forms import RecipeForm, AdminSetupForm
9
+
10
+ User = get_user_model()
11
+
12
+
13
+ def recipe_edit(request, pk):
14
+ recipe = get_object_or_404(Recipe, pk=pk)
15
+ if request.method == "POST":
16
+ form = RecipeForm(request.POST, request.FILES, instance=recipe)
17
+ if form.is_valid():
18
+ form.save()
19
+ return redirect("recipes:admin_list")
20
+ else:
21
+ form = RecipeForm(instance=recipe)
22
+ return render(request, "recipe_form.html", {"form": form, "recipe": recipe})
23
+
24
+
25
+ def recipe_detail(request, slug):
26
+ recipe = get_object_or_404(Recipe, slug=slug)
27
+ return render(request, "detail.html", {"recipe": recipe})
28
+
29
+
30
+ def index(request):
31
+ if not User.objects.filter(is_superuser=True).exists():
32
+ return redirect("setup")
33
+ recipes = Recipe.objects.order_by("-created_at")
34
+ return render(request, "index.html", {"recipes": recipes})
35
+
36
+
37
+ def setup(request):
38
+ """
39
+ First-time setup page: create initial superuser if none exists.
40
+ Visible only while there are no superusers in the DB.
41
+ """
42
+ # do not allow access if a superuser already exists
43
+ if User.objects.filter(is_superuser=True).exists():
44
+ return redirect("index")
45
+
46
+ if request.method == "POST":
47
+ form = AdminSetupForm(request.POST)
48
+ if form.is_valid():
49
+ user = form.save()
50
+ # log in the newly created admin
51
+ user.backend = "django.contrib.auth.backends.ModelBackend"
52
+ login(request, user)
53
+ messages.success(request, "Admin account created and signed in.")
54
+ return redirect(reverse("admin:index"))
55
+ else:
56
+ form = AdminSetupForm()
57
+
58
+ return render(request, "setup.html", {"form": form})
@@ -0,0 +1,16 @@
1
+ """
2
+ WSGI config for sandwitches project.
3
+
4
+ It exposes the WSGI callable as a module-level variable named ``application``.
5
+
6
+ For more information on this file, see
7
+ https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
8
+ """
9
+
10
+ import os
11
+
12
+ from django.core.wsgi import get_wsgi_application
13
+
14
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sandwitches.settings")
15
+
16
+ application = get_wsgi_application()