This commit is contained in:
2026-05-12 19:35:15 +03:00
parent c451a106a1
commit 0fdb8b8a02
17 changed files with 1351 additions and 8 deletions

View File

@@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Sign in — hamprint{% endblock %}
{% block content %}
<div class="max-w-md mx-auto py-8">
<div class="bg-white border border-slate-200 rounded-lg p-8 shadow-sm">
<div class="text-center mb-6">
<div class="inline-grid place-items-center w-12 h-12 rounded-lg bg-amber-500 text-white font-bold text-xl mb-3">h</div>
<h1 class="text-2xl font-bold tracking-tight">Sign in to hamprint</h1>
<p class="text-slate-600 mt-2 text-sm">
Optional &mdash; you can also
<a href="{% url 'submissions:create' %}" class="text-amber-700 font-medium hover:underline">submit as a guest</a>.
</p>
</div>
{% comment %}
The button is a GET to allauth's google_login view -- not a POST form --
because the actual OAuth handshake happens via a 302 redirect to Google.
The `?next=` from `/accounts/login/?next=/wherever/` is forwarded so the
user lands back on the page they were trying to reach.
{% endcomment %}
<a href="{% url 'google_login' %}{% if request.GET.next %}?next={{ request.GET.next|urlencode }}{% endif %}"
class="flex items-center justify-center gap-3 w-full px-4 py-3 rounded-md border border-slate-300 bg-white hover:bg-slate-50 text-slate-900 font-medium shadow-sm">
<svg class="w-5 h-5" viewBox="0 0 24 24" aria-hidden="true">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.75h3.57c2.08-1.92 3.28-4.74 3.28-8.07z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.75c-.99.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.12A6.97 6.97 0 015.46 12c0-.74.13-1.45.36-2.12V7.04H2.18A10.99 10.99 0 001 12c0 1.77.42 3.45 1.18 4.96l3.66-2.84z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.04l3.66 2.84C6.71 7.31 9.14 5.38 12 5.38z"/>
</svg>
Continue with Google
</a>
<div class="mt-6 text-center">
<a href="{% url 'submissions:create' %}" class="text-sm text-slate-600 hover:underline">No thanks, just submit as a guest →</a>
</div>
<div class="mt-8 pt-6 border-t border-slate-200 text-xs text-slate-500 space-y-2">
<p class="flex items-start gap-2"><span class="text-emerald-600 mt-0.5"></span> Signed-in prints skip email confirmation.</p>
<p class="flex items-start gap-2"><span class="text-emerald-600 mt-0.5"></span> See all your past prints in one place.</p>
<p class="flex items-start gap-2"><span class="text-emerald-600 mt-0.5"></span> We don't share your email, ever.</p>
</div>
</div>
</div>
{% endblock %}

53
templates/base.html Normal file
View File

@@ -0,0 +1,53 @@
{% load tailwind_cli %}<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}hamprint — public 3D print dashboard{% endblock %}</title>
{% tailwind_css %}
<style>
body { font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
</style>
{% block extra_head %}{% endblock %}
</head>
<body class="min-h-screen bg-slate-50 text-slate-900">
<nav class="border-b border-slate-200 bg-white">
<div class="mx-auto max-w-6xl px-4 py-3 flex items-center justify-between">
<a href="/" class="flex items-center gap-2">
<span class="inline-grid place-items-center w-8 h-8 rounded-md bg-amber-500 text-white font-bold">h</span>
<span class="font-bold text-lg tracking-tight">hamprint</span>
<span class="hidden sm:inline text-xs text-slate-500 ml-1">· hamlab.lt</span>
</a>
<div class="flex items-center gap-1">
<a href="/" class="px-3 py-1.5 text-sm rounded-md {% if request.path == '/' %}text-slate-900 bg-slate-100 font-medium{% else %}text-slate-700 hover:bg-slate-100{% endif %}">Dashboard</a>
{% if user.is_authenticated %}
<a href="{% url 'dashboard:my_prints' %}" class="px-3 py-1.5 text-sm rounded-md {% if request.resolver_match.view_name == 'dashboard:my_prints' %}text-slate-900 bg-slate-100 font-medium{% else %}text-slate-700 hover:bg-slate-100{% endif %}">My prints</a>
{% endif %}
<a href="/submit/" class="px-3 py-1.5 text-sm rounded-md bg-amber-500 text-white hover:bg-amber-600 font-medium">+ Submit a print</a>
{% if user.is_authenticated %}
<div class="ml-2 flex items-center gap-2 px-2 py-1 rounded-md hover:bg-slate-100">
<span class="w-7 h-7 rounded-full bg-gradient-to-br from-emerald-400 to-blue-500 grid place-items-center text-white text-xs font-bold">{{ user.username|slice:":1"|upper }}</span>
<span class="text-sm text-slate-700 hidden sm:inline">{{ user.username }}</span>
</div>
{% else %}
<a href="{% url 'account_login' %}" class="px-3 py-1.5 text-sm rounded-md text-slate-700 hover:bg-slate-100">Sign in</a>
{% endif %}
</div>
</div>
</nav>
<main class="mx-auto max-w-6xl px-4 py-8">
{% block content %}{% endblock %}
</main>
<footer class="mt-12 border-t border-slate-200 bg-white">
<div class="mx-auto max-w-6xl px-4 py-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 text-sm text-slate-500">
<p>A community service of <a href="https://hamlab.lt" class="font-medium text-slate-700 hover:underline">hamlab.lt</a></p>
{% if user.is_staff %}<p>Operators: <a href="{% url 'admin:index' %}" class="hover:underline">admin panel</a>.</p>{% endif %}
</div>
</footer>
</body>
</html>

View File

@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% load humanize %}
{% block title %}Print dashboard — hamprint{% endblock %}
{% block content %}
<header class="mb-6">
<h1 class="text-2xl font-bold tracking-tight">Print dashboard</h1>
<p class="text-slate-600 mt-1">Every public print job submitted to the hamlab.lt 3d printers. Look up your submission by its codename. Prints are <b>pickup-only</b>.</p>
</header>
{% comment %}
Status filter chips. The "All" chip is active when no ?status= filter is
set. Each specific chip is active when its value matches `active_status`.
{% endcomment %}
<div class="flex flex-wrap items-center gap-2 mb-4">
<a href="?" class="px-3 py-1.5 text-sm rounded-full border {% if not active_status %}border-slate-900 bg-slate-900 text-white{% else %}border-slate-300 bg-white text-slate-700 hover:bg-slate-100{% endif %}">
All <span class="{% if not active_status %}opacity-70{% else %}text-slate-400{% endif %}">{{ counts.total }}</span>
</a>
<a href="?status=verifying" class="px-3 py-1.5 text-sm rounded-full border {% if active_status == 'verifying' %}border-slate-900 bg-slate-900 text-white{% else %}border-slate-300 bg-white text-slate-700 hover:bg-slate-100{% endif %}">
Verifying <span class="{% if active_status == 'verifying' %}opacity-70{% else %}text-slate-400{% endif %}">{{ counts.verifying }}</span>
</a>
<a href="?status=queued" class="px-3 py-1.5 text-sm rounded-full border {% if active_status == 'queued' %}border-slate-900 bg-slate-900 text-white{% else %}border-slate-300 bg-white text-slate-700 hover:bg-slate-100{% endif %}">
Queued <span class="{% if active_status == 'queued' %}opacity-70{% else %}text-slate-400{% endif %}">{{ counts.queued }}</span>
</a>
<a href="?status=printing" class="px-3 py-1.5 text-sm rounded-full border {% if active_status == 'printing' %}border-slate-900 bg-slate-900 text-white{% else %}border-slate-300 bg-white text-slate-700 hover:bg-slate-100{% endif %}">
Printing <span class="{% if active_status == 'printing' %}opacity-70{% else %}text-slate-400{% endif %}">{{ counts.printing }}</span>
</a>
<a href="?status=completed" class="px-3 py-1.5 text-sm rounded-full border {% if active_status == 'completed' %}border-slate-900 bg-slate-900 text-white{% else %}border-slate-300 bg-white text-slate-700 hover:bg-slate-100{% endif %}">
Completed <span class="{% if active_status == 'completed' %}opacity-70{% else %}text-slate-400{% endif %}">{{ counts.completed }}</span>
</a>
</div>
{% if submissions %}
<div class="bg-white border border-slate-200 rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-200 text-slate-600">
<tr class="text-left">
<th class="px-4 py-2.5 font-medium">Codename</th>
<th class="px-4 py-2.5 font-medium">Status</th>
<th class="px-4 py-2.5 font-medium hidden sm:table-cell">Age</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for sub in submissions %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3">
<span class="mono text-amber-700 font-medium">{{ sub.slug }}</span>
{% if user.is_authenticated and sub.submitted_by_id == user.id %}
<span class="ml-2 inline-flex items-center px-1.5 py-0.5 rounded bg-amber-100 text-amber-900 text-[10px] font-semibold uppercase tracking-wide" title="You submitted this print">yours</span>
{% endif %}
</td>
<td class="px-4 py-3">
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full {{ sub.status_badge_class }} text-xs font-medium">{{ sub.get_status_display }}</span>
</td>
<td class="px-4 py-3 hidden sm:table-cell text-slate-500">{{ sub.created_at|naturaltime }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if is_paginated %}
<div class="flex items-center justify-between mt-4 text-sm">
<p class="text-slate-500">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
&middot; {{ page_obj.paginator.count }} total
</p>
<div class="flex items-center gap-1">
{% if page_obj.has_previous %}
<a href="?{% if active_status %}status={{ active_status }}&{% endif %}page={{ page_obj.previous_page_number }}" class="px-3 py-1.5 rounded-md border border-slate-300 bg-white text-slate-700 hover:bg-slate-100">← Previous</a>
{% else %}
<span class="px-3 py-1.5 rounded-md border border-slate-200 text-slate-400 cursor-not-allowed">← Previous</span>
{% endif %}
{% if page_obj.has_next %}
<a href="?{% if active_status %}status={{ active_status }}&{% endif %}page={{ page_obj.next_page_number }}" class="px-3 py-1.5 rounded-md border border-slate-300 bg-white text-slate-700 hover:bg-slate-100">Next →</a>
{% else %}
<span class="px-3 py-1.5 rounded-md border border-slate-200 text-slate-400 cursor-not-allowed">Next →</span>
{% endif %}
</div>
</div>
{% endif %}
{% else %}
<div class="rounded-lg border border-slate-200 bg-white p-8 text-center">
<p class="text-slate-700 font-medium">
{% if active_status %}No submissions in <span class="lowercase">{{ active_status }}</span> right now.{% else %}No submissions yet.{% endif %}
</p>
<p class="text-slate-500 text-sm mt-1">
{% if active_status %}Try one of the other filter chips above, or{% else %}Be the first &mdash;{% endif %}
submit a print and you'll see it appear here once an operator has verified it.
</p>
<a href="{% url 'submissions:create' %}" class="inline-block mt-5 px-4 py-2 rounded-md bg-amber-500 text-white hover:bg-amber-600 font-medium text-sm">+ Submit a print</a>
</div>
{% endif %}
{% if not user.is_authenticated %}
<div class="mt-10 rounded-lg border border-amber-200 bg-amber-50 p-4 flex items-start gap-3">
<div class="w-8 h-8 rounded-full bg-amber-500 text-white grid place-items-center flex-shrink-0 font-bold">?</div>
<div class="text-sm">
<p class="font-semibold text-amber-900">Don't have an account?</p>
<p class="text-amber-800/90 mt-0.5">You don't need one. Just hit <a href="{% url 'submissions:create' %}" class="underline font-medium">Submit a print</a>, give us an email, and we'll send you a codename to track your job.</p>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,85 @@
{% extends "base.html" %}
{% load humanize %}
{% block title %}My prints — hamprint{% endblock %}
{% block content %}
<header class="mb-6 flex items-end justify-between flex-wrap gap-3">
<div>
<h1 class="text-2xl font-bold tracking-tight">My prints</h1>
<p class="text-slate-600 mt-1">
All prints submitted with
<span class="font-medium text-slate-800">{{ user.email|default:user.get_username }}</span>.
</p>
</div>
<div class="text-sm text-slate-500">
<span class="font-medium text-slate-900">{{ counts.total }}</span> total
{% if counts.queued %}· <span class="font-medium text-blue-700">{{ counts.queued }}</span> queued{% endif %}
{% if counts.printing %}· <span class="font-medium text-orange-700">{{ counts.printing }}</span> printing{% endif %}
{% if counts.completed %}· <span class="font-medium text-emerald-700">{{ counts.completed }}</span> completed{% endif %}
</div>
</header>
{% if submissions %}
<div class="bg-white border border-slate-200 rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-50 border-b border-slate-200 text-slate-600">
<tr class="text-left">
<th class="px-4 py-2.5 font-medium">Codename</th>
<th class="px-4 py-2.5 font-medium">Source</th>
<th class="px-4 py-2.5 font-medium">Status</th>
<th class="px-4 py-2.5 font-medium hidden sm:table-cell">Submitted</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{% for sub in submissions %}
<tr class="hover:bg-slate-50">
<td class="px-4 py-3">
<span class="mono text-amber-700 font-medium">{{ sub.slug }}</span>
</td>
<td class="px-4 py-3 text-slate-600">{{ sub.source_label }}</td>
<td class="px-4 py-3">
<span class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full {{ sub.status_badge_class }} text-xs font-medium">
{{ sub.get_status_display }}
</span>
</td>
<td class="px-4 py-3 hidden sm:table-cell text-slate-500">{{ sub.created_at|naturaltime }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if is_paginated %}
<div class="flex items-center justify-between mt-4 text-sm">
<p class="text-slate-500">
Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}
&middot; {{ page_obj.paginator.count }} total
</p>
<div class="flex items-center gap-1">
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}" class="px-3 py-1.5 rounded-md border border-slate-300 bg-white text-slate-700 hover:bg-slate-100">← Previous</a>
{% else %}
<span class="px-3 py-1.5 rounded-md border border-slate-200 text-slate-400 cursor-not-allowed">← Previous</span>
{% endif %}
{% if page_obj.has_next %}
<a href="?page={{ page_obj.next_page_number }}" class="px-3 py-1.5 rounded-md border border-slate-300 bg-white text-slate-700 hover:bg-slate-100">Next →</a>
{% else %}
<span class="px-3 py-1.5 rounded-md border border-slate-200 text-slate-400 cursor-not-allowed">Next →</span>
{% endif %}
</div>
</div>
{% endif %}
{% else %}
<div class="rounded-lg border border-slate-200 bg-white p-8 text-center">
<p class="text-slate-700 font-medium">No prints yet.</p>
<p class="text-slate-500 text-sm mt-1">Once you submit one, it'll show up here.</p>
<a href="{% url 'submissions:create' %}" class="inline-block mt-5 px-4 py-2 rounded-md bg-amber-500 text-white hover:bg-amber-600 font-medium text-sm">+ Submit a print</a>
</div>
{% endif %}
<div class="mt-6 flex items-center justify-between text-sm">
<a href="{% url 'submissions:create' %}" class="text-amber-700 hover:underline font-medium">+ Submit another print</a>
<a href="{% url 'account_logout' %}" class="text-slate-500 hover:underline">Sign out</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,189 @@
{% extends "base.html" %}
{% block title %}Submit a print — hamprint{% endblock %}
{% block extra_head %}
<style>
/* Show/hide source-input panes based on the radio selection. The radios
live inside a grid <div> while the panes are siblings of the grid, so a
plain `~` combinator never matches. `fieldset:has(...)` reaches across
the grid and selects the right pane regardless of nesting depth. */
.src-pane { display: none; }
fieldset:has(#src-upload:checked) .src-pane[data-pane="upload"],
fieldset:has(#src-printables:checked) .src-pane[data-pane="url"],
fieldset:has(#src-makerworld:checked) .src-pane[data-pane="url"],
fieldset:has(#src-thingiverse:checked) .src-pane[data-pane="url"] { display: block; }
</style>
{% endblock %}
{% block content %}
<div class="max-w-3xl mx-auto">
<header class="mb-6">
<h1 class="text-2xl font-bold tracking-tight">Submit a print</h1>
<p class="text-slate-600 mt-1">Anyone can submit a job. We'll send you a codename to follow it on the dashboard.</p>
</header>
{% if user.is_authenticated %}
<div class="rounded-lg border border-emerald-200 bg-emerald-50 p-4 mb-6 flex items-start gap-3 text-sm">
<svg class="w-5 h-5 text-emerald-600 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd"/></svg>
<p class="text-emerald-900">Signed in as <span class="font-medium">{{ user.email|default:user.get_username }}</span>. No email confirmation needed.</p>
</div>
{% else %}
<div class="rounded-lg border border-slate-200 bg-white p-4 mb-6 flex items-start gap-3 text-sm">
<span class="px-2 py-0.5 rounded-md bg-slate-100 text-slate-700 text-xs font-medium uppercase tracking-wide">Guest</span>
<p class="text-slate-700">You're submitting anonymously. We'll email you a confirmation link &mdash; click it within <span class="font-medium">24 hours</span> to put your print in the queue. <a href="{% url 'account_login' %}" class="text-amber-700 font-medium hover:underline">Sign in with Google</a> to skip this step.</p>
</div>
{% endif %}
<form method="post" enctype="multipart/form-data" class="space-y-6 bg-white border border-slate-200 rounded-lg p-6">
{% csrf_token %}
{% if form.non_field_errors %}
<div class="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-800">
{{ form.non_field_errors|join:" " }}
</div>
{% endif %}
{% comment %}
Source-type radio cards. Hardcoded (not looped) so Tailwind's scanner
sees the literal `peer/<name>` and `peer-checked/<name>:` class strings
and emits the matching CSS rules.
{% endcomment %}
<fieldset>
<legend class="block text-sm font-medium text-slate-900 mb-2">Where is the model coming from? <span class="text-red-500">*</span></legend>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2 mb-4">
<input type="radio" name="{{ form.source_type.html_name }}" id="src-upload" value="upload" class="hidden peer/upload" {% if form.source_type.value == "upload" or not form.source_type.value %}checked{% endif %}>
<label for="src-upload" class="cursor-pointer border border-slate-300 rounded-md px-3 py-2 text-center text-sm hover:border-amber-500 peer-checked/upload:border-amber-500 peer-checked/upload:bg-amber-50 peer-checked/upload:text-amber-900 peer-checked/upload:font-medium">.stl file</label>
<input type="radio" name="{{ form.source_type.html_name }}" id="src-printables" value="printables" class="hidden peer/printables" {% if form.source_type.value == "printables" %}checked{% endif %}>
<label for="src-printables" class="cursor-pointer border border-slate-300 rounded-md px-3 py-2 text-center text-sm hover:border-amber-500 peer-checked/printables:border-amber-500 peer-checked/printables:bg-amber-50 peer-checked/printables:text-amber-900 peer-checked/printables:font-medium">Printables</label>
<input type="radio" name="{{ form.source_type.html_name }}" id="src-makerworld" value="makerworld" class="hidden peer/makerworld" {% if form.source_type.value == "makerworld" %}checked{% endif %}>
<label for="src-makerworld" class="cursor-pointer border border-slate-300 rounded-md px-3 py-2 text-center text-sm hover:border-amber-500 peer-checked/makerworld:border-amber-500 peer-checked/makerworld:bg-amber-50 peer-checked/makerworld:text-amber-900 peer-checked/makerworld:font-medium">MakerWorld</label>
<input type="radio" name="{{ form.source_type.html_name }}" id="src-thingiverse" value="thingiverse" class="hidden peer/thingiverse" {% if form.source_type.value == "thingiverse" %}checked{% endif %}>
<label for="src-thingiverse" class="cursor-pointer border border-slate-300 rounded-md px-3 py-2 text-center text-sm hover:border-amber-500 peer-checked/thingiverse:border-amber-500 peer-checked/thingiverse:bg-amber-50 peer-checked/thingiverse:text-amber-900 peer-checked/thingiverse:font-medium">Thingiverse</label>
</div>
{% comment %}
Upload pane. The native file input stays sr-only and is triggered by
the wrapping <label> dropzone. After selection, the prompt text is
swapped for the filename + size via the inline script below.
{% endcomment %}
<div class="src-pane" data-pane="upload">
<label class="block cursor-pointer">
<div class="border-2 border-dashed border-slate-300 rounded-md p-6 text-center hover:border-amber-500 hover:bg-amber-50/40 transition">
<svg class="w-8 h-8 mx-auto text-slate-400" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 16.5V9.75m0 0L9 12.75M12 9.75l3 3M3 17.25V18a2.25 2.25 0 002.25 2.25h13.5A2.25 2.25 0 0021 18v-.75"/></svg>
<p data-stl-prompt class="mt-2 text-sm font-medium text-slate-700">Drop your .stl here or click to browse</p>
<p data-stl-filename class="hidden mt-2 text-sm font-medium text-amber-700"></p>
<p class="text-xs text-slate-500 mt-1">Raw <span class="mono">.stl</span> only, max 4 MB. No <span class="mono">.3mf</span>, <span class="mono">.gcode</span>, <span class="mono">.zip</span>, etc.</p>
</div>
{{ form.stl_file }}
</label>
{% if form.stl_file.errors %}<p class="text-xs text-red-600 mt-1">{{ form.stl_file.errors|join:" " }}</p>{% endif %}
</div>
{% comment %}
Shared URL pane. The label, placeholder, and help text are rewritten
per source-type by the inline script below so the user gets host-
specific guidance even though there's only one real <input>.
{% endcomment %}
<div class="src-pane" data-pane="url">
<label for="{{ form.source_url.id_for_label }}" data-url-label class="block text-sm font-medium text-slate-700 mb-1">Model URL</label>
{{ form.source_url }}
<p data-url-help class="text-xs text-slate-500 mt-1">Must be a link on <span class="mono">printables.com</span>, <span class="mono">makerworld.com</span>, or <span class="mono">thingiverse.com</span> matching your selection above.</p>
{% if form.source_url.errors %}<p class="text-xs text-red-600 mt-1">{{ form.source_url.errors|join:" " }}</p>{% endif %}
</div>
<p class="text-xs text-slate-500 mt-3">Got something from elsewhere? Download the <span class="mono">.stl</span> and upload it as a file.</p>
{% if form.source_type.errors %}<p class="text-xs text-red-600 mt-1">{{ form.source_type.errors|join:" " }}</p>{% endif %}
</fieldset>
{# Filament #}
<div>
<label for="{{ form.requested_filament.id_for_label }}" class="block text-sm font-medium text-slate-900 mb-1">Filament</label>
{{ form.requested_filament }}
<p class="text-xs text-slate-500 mt-1">Only filaments currently loaded at hamlab.lt are listed. The list is curated by operators &mdash; out-of-stock options are hidden.</p>
{% if form.requested_filament.errors %}<p class="text-xs text-red-600 mt-1">{{ form.requested_filament.errors|join:" " }}</p>{% endif %}
</div>
{# Notes (private) #}
<div>
<label for="{{ form.notes_for_op.id_for_label }}" class="block text-sm font-medium text-slate-900 mb-1">
Notes for the operator
<span class="ml-1 inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-slate-100 text-slate-600 text-[10px] font-medium uppercase tracking-wide align-middle">
<svg class="w-3 h-3" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"/></svg>
Private
</span>
</label>
{{ form.notes_for_op }}
<p class="text-xs text-slate-500 mt-1">Only the operator sees these &mdash; they're not shown on your public submission page.</p>
{% if form.notes_for_op.errors %}<p class="text-xs text-red-600 mt-1">{{ form.notes_for_op.errors|join:" " }}</p>{% endif %}
</div>
{% if form.guest_email %}
<div class="border-t border-slate-200 pt-6">
<label for="{{ form.guest_email.id_for_label }}" class="block text-sm font-medium text-slate-900 mb-1">Your email <span class="text-red-500">*</span></label>
{{ form.guest_email }}
<p class="text-xs text-slate-500 mt-1">We'll send a confirmation link. The submission disappears in 24 hours if not confirmed. Never shown publicly.</p>
{% if form.guest_email.errors %}<p class="text-xs text-red-600 mt-1">{{ form.guest_email.errors|join:" " }}</p>{% endif %}
</div>
{% endif %}
<div class="flex items-center justify-end gap-3 border-t border-slate-200 pt-6">
<a href="{% url 'dashboard:index' %}" class="px-4 py-2 text-sm rounded-md text-slate-700 hover:bg-slate-100">Cancel</a>
<button type="submit" class="px-4 py-2 text-sm rounded-md bg-amber-500 text-white hover:bg-amber-600 font-medium">Submit print</button>
</div>
</form>
</div>
<script>
// Filename feedback: swap "Drop your .stl here" for the selected file's name
// + size as soon as the user picks something via the hidden file input.
(function() {
var input = document.getElementById("{{ form.stl_file.id_for_label }}");
var prompt = document.querySelector("[data-stl-prompt]");
var filename = document.querySelector("[data-stl-filename]");
if (!input || !prompt || !filename) return;
input.addEventListener("change", function(e) {
var file = e.target.files && e.target.files[0];
if (file) {
prompt.classList.add("hidden");
filename.classList.remove("hidden");
filename.textContent = "✓ " + file.name + " (" + Math.round(file.size / 1024) + " KB)";
} else {
prompt.classList.remove("hidden");
filename.classList.add("hidden");
filename.textContent = "";
}
});
})();
// URL-pane copy: rewrite the label, the input's placeholder, and the help
// text per source-type so the user sees host-specific guidance.
(function() {
var COPY = {
printables: { label: "Printables.com URL", ph: "https://www.printables.com/model/…", help: "Must be a printables.com link." },
makerworld: { label: "MakerWorld URL", ph: "https://makerworld.com/en/models/…", help: "Must be a makerworld.com link." },
thingiverse: { label: "Thingiverse URL", ph: "https://www.thingiverse.com/thing:…", help: "Must be a thingiverse.com link." }
};
var labelEl = document.querySelector("[data-url-label]");
var inputEl = document.getElementById("{{ form.source_url.id_for_label }}");
var helpEl = document.querySelector("[data-url-help]");
function apply(sourceType) {
var c = COPY[sourceType];
if (!c) return;
if (labelEl) labelEl.textContent = c.label;
if (inputEl) inputEl.placeholder = c.ph;
if (helpEl) helpEl.textContent = c.help;
}
document.querySelectorAll('input[name="{{ form.source_type.html_name }}"]').forEach(function(radio) {
radio.addEventListener("change", function(e) { apply(e.target.value); });
if (radio.checked) apply(radio.value);
});
})();
</script>
{% endblock %}