fix: polish edit forms and detail pages

- Skip payload in tracing::instrument to avoid logging base64 image data
- Add blob: to CSP img-src for image preview support
- Add deferred_upload_with_preview macro for edit form image previews
  with Replace/Remove buttons and proper DOM cleanup on replacement
- Fix datastar-fetch finished handler (evt.detail.response is undefined
  for redirect scripts)
- Display brew time in M:SS format on edit form
- Add full-width Save Changes button with check icon and Cancel button
  to all edit forms
- Fix country flag emoji spacing on cafe and cup detail pages
- Add "View on Map" Google Maps link to cafe and cup detail pages
This commit is contained in:
Jon Seager 2026-02-10 19:25:33 +00:00
parent 9650735057
commit 4ee8cf7964
No known key found for this signature in database
21 changed files with 225 additions and 52 deletions

View file

@ -52,7 +52,7 @@ pub(crate) async fn load_bag_page(
Ok(BagPageData { bags, navigator }) Ok(BagPageData { bags, navigator })
} }
#[tracing::instrument(skip(state, _auth_user, headers, query))] #[tracing::instrument(skip(state, _auth_user, headers, query, payload))]
pub(crate) async fn create_bag( pub(crate) async fn create_bag(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
@ -156,7 +156,7 @@ impl UpdateBagSubmission {
} }
} }
#[tracing::instrument(skip(state, _auth_user, headers, query))] #[tracing::instrument(skip(state, _auth_user, headers, query, payload))]
pub(crate) async fn update_bag( pub(crate) async fn update_bag(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,

View file

@ -238,7 +238,7 @@ impl NewBrewSubmission {
} }
} }
#[tracing::instrument(skip(state, _auth_user, headers, query))] #[tracing::instrument(skip(state, _auth_user, headers, query, payload))]
pub(crate) async fn create_brew( pub(crate) async fn create_brew(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
@ -375,7 +375,7 @@ impl UpdateBrewSubmission {
} }
} }
#[tracing::instrument(skip(state, _auth_user, headers))] #[tracing::instrument(skip(state, _auth_user, headers, payload))]
pub(crate) async fn update_brew( pub(crate) async fn update_brew(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,

View file

@ -87,7 +87,7 @@ impl NewCafeSubmission {
} }
} }
#[tracing::instrument(skip(state, _auth_user, headers, query))] #[tracing::instrument(skip(state, _auth_user, headers, query, payload))]
pub(crate) async fn create_cafe( pub(crate) async fn create_cafe(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
@ -175,7 +175,7 @@ impl UpdateCafeSubmission {
} }
} }
#[tracing::instrument(skip(state, _auth_user, headers))] #[tracing::instrument(skip(state, _auth_user, headers, payload))]
pub(crate) async fn update_cafe( pub(crate) async fn update_cafe(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,

View file

@ -47,7 +47,7 @@ pub(crate) async fn load_cup_page(
)) ))
} }
#[tracing::instrument(skip(state, _auth_user, headers, query))] #[tracing::instrument(skip(state, _auth_user, headers, query, payload))]
pub(crate) async fn create_cup( pub(crate) async fn create_cup(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
@ -116,7 +116,7 @@ impl UpdateCupSubmission {
} }
} }
#[tracing::instrument(skip(state, _auth_user, headers))] #[tracing::instrument(skip(state, _auth_user, headers, payload))]
pub(crate) async fn update_cup( pub(crate) async fn update_cup(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,

View file

@ -49,7 +49,7 @@ pub(crate) async fn load_gear_page(
)) ))
} }
#[tracing::instrument(skip(state, _auth_user, headers, query))] #[tracing::instrument(skip(state, _auth_user, headers, query, payload))]
pub(crate) async fn create_gear( pub(crate) async fn create_gear(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
@ -148,7 +148,7 @@ impl UpdateGearSubmission {
} }
} }
#[tracing::instrument(skip(state, _auth_user, headers))] #[tracing::instrument(skip(state, _auth_user, headers, payload))]
pub(crate) async fn update_gear( pub(crate) async fn update_gear(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,

View file

@ -86,7 +86,7 @@ impl NewRoasterSubmission {
} }
} }
#[tracing::instrument(skip(state, _auth_user, headers, query))] #[tracing::instrument(skip(state, _auth_user, headers, query, payload))]
pub(crate) async fn create_roaster( pub(crate) async fn create_roaster(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
@ -168,7 +168,7 @@ impl UpdateRoasterSubmission {
} }
} }
#[tracing::instrument(skip(state, _auth_user, headers))] #[tracing::instrument(skip(state, _auth_user, headers, payload))]
pub(crate) async fn update_roaster( pub(crate) async fn update_roaster(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,

View file

@ -48,7 +48,7 @@ pub(crate) async fn load_roast_page(
)) ))
} }
#[tracing::instrument(skip(state, _auth_user, headers, query))] #[tracing::instrument(skip(state, _auth_user, headers, query, payload))]
pub(crate) async fn create_roast( pub(crate) async fn create_roast(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,
@ -214,7 +214,7 @@ impl UpdateRoastSubmission {
} }
} }
#[tracing::instrument(skip(state, _auth_user, headers))] #[tracing::instrument(skip(state, _auth_user, headers, payload))]
pub(crate) async fn update_roast( pub(crate) async fn update_roast(
State(state): State<AppState>, State(state): State<AppState>,
_auth_user: AuthenticatedUser, _auth_user: AuthenticatedUser,

View file

@ -56,7 +56,7 @@ pub fn app_router(state: AppState) -> axum::Router {
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; \ script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; \
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \ style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; \
font-src 'self' https://fonts.gstatic.com; \ font-src 'self' https://fonts.gstatic.com; \
img-src 'self' data:; \ img-src 'self' data: blob:; \
frame-ancestors 'none'", frame-ancestors 'none'",
), ),
)) ))

View file

@ -11,6 +11,7 @@ pub struct CafeDetailView {
pub country: String, pub country: String,
pub country_flag: String, pub country_flag: String,
pub website: Option<String>, pub website: Option<String>,
pub map_url: String,
pub map_countries: String, pub map_countries: String,
pub map_max: u32, pub map_max: u32,
pub legend_entries: Vec<LegendEntry>, pub legend_entries: Vec<LegendEntry>,
@ -24,6 +25,10 @@ impl CafeDetailView {
.map(iso_to_flag_emoji) .map(iso_to_flag_emoji)
.unwrap_or_default(); .unwrap_or_default();
let (map_countries, map_max) = build_map_data(&[(&cafe.country, 1)]); let (map_countries, map_max) = build_map_data(&[(&cafe.country, 1)]);
let map_url = format!(
"https://www.google.com/maps?q={},{}",
cafe.latitude, cafe.longitude
);
Self { Self {
id: cafe.id.to_string(), id: cafe.id.to_string(),
@ -32,6 +37,7 @@ impl CafeDetailView {
country_flag, country_flag,
country: cafe.country, country: cafe.country,
website: cafe.website, website: cafe.website,
map_url,
map_countries, map_countries,
map_max, map_max,
legend_entries: vec![LegendEntry { legend_entries: vec![LegendEntry {

View file

@ -60,6 +60,7 @@ pub struct CupDetailView {
pub cafe_country: String, pub cafe_country: String,
pub cafe_country_flag: String, pub cafe_country_flag: String,
pub cafe_website: Option<String>, pub cafe_website: Option<String>,
pub cafe_map_url: String,
// Map // Map
pub map_countries: String, pub map_countries: String,
pub map_max: u32, pub map_max: u32,
@ -111,6 +112,10 @@ impl CupDetailView {
cafe_country: cafe.country.clone(), cafe_country: cafe.country.clone(),
cafe_country_flag, cafe_country_flag,
cafe_website: cafe.website.clone(), cafe_website: cafe.website.clone(),
cafe_map_url: format!(
"https://www.google.com/maps?q={},{}",
cafe.latitude, cafe.longitude
),
roaster_slug: roaster.slug.clone(), roaster_slug: roaster.slug.clone(),
roast_slug: roast.slug.clone(), roast_slug: roast.slug.clone(),
cafe_slug: cafe.slug.clone(), cafe_slug: cafe.slug.clone(),

View file

@ -50,7 +50,28 @@ customElements.define(
// Deferred mode: store data URL in a target hidden input // Deferred mode: store data URL in a target hidden input
const targetId = this.getAttribute("target-input"); const targetId = this.getAttribute("target-input");
if (targetId) { if (targetId) {
document.getElementById(targetId).value = dataUrl; const hiddenInput = document.getElementById(targetId);
hiddenInput.value = dataUrl;
// Remove existing server image preview (edit forms)
const existing = document.getElementById(`${targetId}-existing`);
if (existing) {
existing.remove();
// Create standalone preview after the hidden input since
// the Replace button (this element) was inside the removed container
let preview = hiddenInput.nextElementSibling;
if (
!preview ||
!preview.classList.contains("image-upload-preview")
) {
preview = document.createElement("div");
preview.className =
"image-upload-preview h-48 w-full bg-cover bg-center rounded-lg border";
hiddenInput.insertAdjacentElement("afterend", preview);
}
preview.style.backgroundImage = `url('${dataUrl}')`;
return;
}
} }
this._showPreview(dataUrl); this._showPreview(dataUrl);
} }

View file

@ -41,7 +41,21 @@
<div> <div>
<dt class="text-text-muted">Country</dt> <dt class="text-text-muted">Country</dt>
<dd class="font-medium text-text"> <dd class="font-medium text-text">
{% if !cafe.country_flag.is_empty() %}{{ cafe.country_flag }}{% endif %}{{ cafe.country }} {% if !cafe.country_flag.is_empty() %}
<span class="mr-1">{{ cafe.country_flag }}</span>
{% endif %}{{ cafe.country }}
</dd>
</div>
<div>
<dt class="text-text-muted">Location</dt>
<dd class="font-medium">
<a
href="{{ cafe.map_url }}"
target="_blank"
rel="noreferrer noopener"
class="text-accent hover:text-accent-hover transition"
>View on Map</a
>
</dd> </dd>
</div> </div>
{% if let Some(url) = cafe.website %} {% if let Some(url) = cafe.website %}

View file

@ -62,13 +62,27 @@
<div> <div>
<dt class="text-text-muted">Country</dt> <dt class="text-text-muted">Country</dt>
<dd class="font-medium text-text"> <dd class="font-medium text-text">
{% if !cup.cafe_country_flag.is_empty() %}{{ cup.cafe_country_flag }}{% endif %}{{ cup.cafe_country }} {% if !cup.cafe_country_flag.is_empty() %}
<span class="mr-1">{{ cup.cafe_country_flag }}</span>
{% endif %}{{ cup.cafe_country }}
</dd> </dd>
</div> </div>
<div> <div>
<dt class="text-text-muted">City</dt> <dt class="text-text-muted">City</dt>
<dd class="font-medium text-text">{{ cup.cafe_city }}</dd> <dd class="font-medium text-text">{{ cup.cafe_city }}</dd>
</div> </div>
<div>
<dt class="text-text-muted">Location</dt>
<dd class="font-medium">
<a
href="{{ cup.cafe_map_url }}"
target="_blank"
rel="noreferrer noopener"
class="text-accent hover:text-accent-hover transition"
>View on Map</a
>
</dd>
</div>
{% if let Some(url) = cup.cafe_website %} {% if let Some(url) = cup.cafe_website %}
<div> <div>
<dt class="text-text-muted">Website</dt> <dt class="text-text-muted">Website</dt>

View file

@ -17,7 +17,7 @@
data-signals:_amount="{{ amount }}" data-signals:_amount="{{ amount }}"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/bags/{{ id }}', {contentType: 'form'})" data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/bags/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return; data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Bag updated'); window.location.href = evt.detail.response.headers.get('location') || '/bags/{{ id }}' } if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Bag updated') }
else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }" else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }"
> >
<div class="flex flex-col gap-1 text-sm"> <div class="flex flex-col gap-1 text-sm">
@ -82,17 +82,25 @@
class="text-sm text-error" class="text-sm text-error"
role="alert" role="alert"
></p> ></p>
<div class="flex items-center justify-end"> <div class="flex flex-col gap-2">
<button <button
type="submit" type="submit"
class="inline-flex items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50" class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50"
data-attr:disabled="$_submitting" data-attr:disabled="$_submitting"
> >
<span data-show="$_submitting" style="display:none" <span data-show="$_submitting" style="display:none"
>{{ icons::spinner("h-4 w-4") }}</span >{{ icons::spinner("h-4 w-4") }}</span
> >
<span data-show="!$_submitting">{{ icons::check("h-4 w-4") }}</span>
Save Changes Save Changes
</button> </button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
Cancel
</button>
</div> </div>
</form> </form>
</section> </section>

View file

@ -27,7 +27,7 @@
data-signals:_qn-over-extracted="{% if quick_notes.contains("over-extracted") %}true{% else %}false{% endif %}" data-signals:_qn-over-extracted="{% if quick_notes.contains("over-extracted") %}true{% else %}false{% endif %}"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/brews/{{ id }}', {contentType: 'form'})" data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/brews/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return; data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Brew updated'); window.location.href = evt.detail.response.headers.get('location') || '/brews/{{ id }}' } if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Brew updated') }
else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }" else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }"
> >
<!-- Coffee --> <!-- Coffee -->
@ -273,28 +273,31 @@
<div class="flex flex-col gap-1 text-sm"> <div class="flex flex-col gap-1 text-sm">
<span <span
class="text-xs font-semibold text-text-muted uppercase tracking-wide" class="text-xs font-semibold text-text-muted uppercase tracking-wide"
>Time (s)</span >Time</span
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button <button
type="button" type="button"
class="btn-adjust" class="btn-adjust"
data-on:click="$_brewTime = Math.max(5, Number($_brewTime) - 5)" data-on:click="$_brewTime = Math.max(5, $_brewTime - 5)"
> >
- -
</button> </button>
<input <input
type="number" type="hidden"
name="brew_time" name="brew_time"
step="1" data-attr:value="$_brewTime"
min="0" />
<input
type="text"
readonly
class="input-field flex-1 text-center" class="input-field flex-1 text-center"
data-bind:_brew-time data-attr:value="Math.floor($_brewTime / 60) + ':' + String($_brewTime % 60).padStart(2, '0')"
/> />
<button <button
type="button" type="button"
class="btn-adjust" class="btn-adjust"
data-on:click="$_brewTime = Number($_brewTime) + 5" data-on:click="$_brewTime = $_brewTime + 5"
> >
+ +
</button> </button>
@ -357,7 +360,7 @@
/> />
</div> </div>
{{ img::deferred_upload("edit-brew-image", "Brew Image") }} {{ img::deferred_upload_with_preview("edit-brew-image", "Brew Image", "brew", id, image_url) }}
<p <p
data-show="$_submitError" data-show="$_submitError"
data-text="$_submitError" data-text="$_submitError"
@ -365,17 +368,25 @@
class="text-sm text-error" class="text-sm text-error"
role="alert" role="alert"
></p> ></p>
<div class="flex items-center justify-end"> <div class="flex flex-col gap-2">
<button <button
type="submit" type="submit"
class="inline-flex items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50" class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50"
data-attr:disabled="$_submitting" data-attr:disabled="$_submitting"
> >
<span data-show="$_submitting" style="display:none" <span data-show="$_submitting" style="display:none"
>{{ icons::spinner("h-4 w-4") }}</span >{{ icons::spinner("h-4 w-4") }}</span
> >
<span data-show="!$_submitting">{{ icons::check("h-4 w-4") }}</span>
Save Changes Save Changes
</button> </button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
Cancel
</button>
</div> </div>
</form> </form>
</section> </section>

View file

@ -22,7 +22,7 @@
data-signals:_website="'{{ website }}'" data-signals:_website="'{{ website }}'"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/cafes/{{ id }}', {contentType: 'form'})" data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/cafes/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return; data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Cafe updated'); window.location.href = evt.detail.response.headers.get('location') || '/cafes/{{ id }}' } if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Cafe updated') }
else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }" else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }"
> >
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
@ -117,7 +117,7 @@
/> />
</label> </label>
</div> </div>
{{ img::deferred_upload("edit-cafe-image", "Cafe Image") }} {{ img::deferred_upload_with_preview("edit-cafe-image", "Cafe Image", "cafe", id, image_url) }}
<p <p
data-show="$_submitError" data-show="$_submitError"
data-text="$_submitError" data-text="$_submitError"
@ -125,17 +125,25 @@
class="text-sm text-error" class="text-sm text-error"
role="alert" role="alert"
></p> ></p>
<div class="flex items-center justify-end"> <div class="flex flex-col gap-2">
<button <button
type="submit" type="submit"
class="inline-flex items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50" class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50"
data-attr:disabled="$_submitting" data-attr:disabled="$_submitting"
> >
<span data-show="$_submitting" style="display:none" <span data-show="$_submitting" style="display:none"
>{{ icons::spinner("h-4 w-4") }}</span >{{ icons::spinner("h-4 w-4") }}</span
> >
<span data-show="!$_submitting">{{ icons::check("h-4 w-4") }}</span>
Save Changes Save Changes
</button> </button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
Cancel
</button>
</div> </div>
</form> </form>
</section> </section>

View file

@ -16,7 +16,7 @@
data-signals:_submit-error="''" data-signals:_submit-error="''"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/cups/{{ id }}', {contentType: 'form'})" data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/cups/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return; data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Cup updated'); window.location.href = evt.detail.response.headers.get('location') || '/cups/{{ id }}' } if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Cup updated') }
else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }" else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }"
> >
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
@ -71,7 +71,7 @@
</searchable-select> </searchable-select>
</div> </div>
</div> </div>
{{ img::deferred_upload("edit-cup-image", "Cup Image") }} {{ img::deferred_upload_with_preview("edit-cup-image", "Cup Image", "cup", id, image_url) }}
<p <p
data-show="$_submitError" data-show="$_submitError"
data-text="$_submitError" data-text="$_submitError"
@ -79,17 +79,25 @@
class="text-sm text-error" class="text-sm text-error"
role="alert" role="alert"
></p> ></p>
<div class="flex items-center justify-end"> <div class="flex flex-col gap-2">
<button <button
type="submit" type="submit"
class="inline-flex items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50" class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50"
data-attr:disabled="$_submitting" data-attr:disabled="$_submitting"
> >
<span data-show="$_submitting" style="display:none" <span data-show="$_submitting" style="display:none"
>{{ icons::spinner("h-4 w-4") }}</span >{{ icons::spinner("h-4 w-4") }}</span
> >
<span data-show="!$_submitting">{{ icons::check("h-4 w-4") }}</span>
Save Changes Save Changes
</button> </button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
Cancel
</button>
</div> </div>
</form> </form>
</section> </section>

View file

@ -18,7 +18,7 @@
data-signals:_model="'{{ model }}'" data-signals:_model="'{{ model }}'"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/gear/{{ id }}', {contentType: 'form'})" data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/gear/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return; data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Gear updated'); window.location.href = evt.detail.response.headers.get('location') || '/gear/{{ id }}' } if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Gear updated') }
else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }" else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }"
> >
<div class="grid gap-4 sm:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-3">
@ -63,7 +63,7 @@
/> />
</label> </label>
</div> </div>
{{ img::deferred_upload("edit-gear-image", "Gear Image") }} {{ img::deferred_upload_with_preview("edit-gear-image", "Gear Image", "gear", id, image_url) }}
<p <p
data-show="$_submitError" data-show="$_submitError"
data-text="$_submitError" data-text="$_submitError"
@ -71,17 +71,25 @@
class="text-sm text-error" class="text-sm text-error"
role="alert" role="alert"
></p> ></p>
<div class="flex items-center justify-end"> <div class="flex flex-col gap-2">
<button <button
type="submit" type="submit"
class="inline-flex items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50" class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50"
data-attr:disabled="$_submitting" data-attr:disabled="$_submitting"
> >
<span data-show="$_submitting" style="display:none" <span data-show="$_submitting" style="display:none"
>{{ icons::spinner("h-4 w-4") }}</span >{{ icons::spinner("h-4 w-4") }}</span
> >
<span data-show="!$_submitting">{{ icons::check("h-4 w-4") }}</span>
Save Changes Save Changes
</button> </button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
Cancel
</button>
</div> </div>
</form> </form>
</section> </section>

View file

@ -22,7 +22,7 @@
data-signals:_tasting-notes="'{{ tasting_notes }}'" data-signals:_tasting-notes="'{{ tasting_notes }}'"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/roasts/{{ id }}', {contentType: 'form'})" data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/roasts/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return; data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Roast updated'); window.location.href = evt.detail.response.headers.get('location') || '/roasts/{{ id }}' } if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Roast updated') }
else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }" else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }"
> >
<div class="flex flex-col gap-1 text-sm"> <div class="flex flex-col gap-1 text-sm">
@ -139,7 +139,7 @@
></textarea> ></textarea>
</label> </label>
</div> </div>
{{ img::deferred_upload("edit-roast-image", "Roast Image") }} {{ img::deferred_upload_with_preview("edit-roast-image", "Roast Image", "roast", id, image_url) }}
<p <p
data-show="$_submitError" data-show="$_submitError"
data-text="$_submitError" data-text="$_submitError"
@ -147,17 +147,25 @@
class="text-sm text-error" class="text-sm text-error"
role="alert" role="alert"
></p> ></p>
<div class="flex items-center justify-end"> <div class="flex flex-col gap-2">
<button <button
type="submit" type="submit"
class="inline-flex items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50" class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50"
data-attr:disabled="$_submitting" data-attr:disabled="$_submitting"
> >
<span data-show="$_submitting" style="display:none" <span data-show="$_submitting" style="display:none"
>{{ icons::spinner("h-4 w-4") }}</span >{{ icons::spinner("h-4 w-4") }}</span
> >
<span data-show="!$_submitting">{{ icons::check("h-4 w-4") }}</span>
Save Changes Save Changes
</button> </button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
Cancel
</button>
</div> </div>
</form> </form>
</section> </section>

View file

@ -20,7 +20,7 @@
data-signals:_homepage="'{{ homepage }}'" data-signals:_homepage="'{{ homepage }}'"
data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/roasters/{{ id }}', {contentType: 'form'})" data-on:submit="$_submitting = true; $_submitError = ''; @put('/api/v1/roasters/{{ id }}', {contentType: 'form'})"
data-on:datastar-fetch="if (!$_submitting) return; data-on:datastar-fetch="if (!$_submitting) return;
if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Roaster updated'); window.location.href = evt.detail.response.headers.get('location') || '/roasters/{{ id }}' } if (evt.detail.type === 'finished') { $_submitting = false; sessionStorage.setItem('toast', 'Roaster updated') }
else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }" else if (evt.detail.type === 'error') { $_submitting = false; $_submitError = 'Failed to save changes.' }"
> >
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
@ -81,7 +81,7 @@
/> />
</label> </label>
</div> </div>
{{ img::deferred_upload("edit-roaster-image", "Roaster Image") }} {{ img::deferred_upload_with_preview("edit-roaster-image", "Roaster Image", "roaster", id, image_url) }}
<p <p
data-show="$_submitError" data-show="$_submitError"
data-text="$_submitError" data-text="$_submitError"
@ -89,17 +89,25 @@
class="text-sm text-error" class="text-sm text-error"
role="alert" role="alert"
></p> ></p>
<div class="flex items-center justify-end"> <div class="flex flex-col gap-2">
<button <button
type="submit" type="submit"
class="inline-flex items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50" class="inline-flex w-full items-center justify-center gap-2 rounded-md bg-accent px-4 py-2 text-sm font-semibold text-accent-text transition hover:bg-accent-hover disabled:opacity-50"
data-attr:disabled="$_submitting" data-attr:disabled="$_submitting"
> >
<span data-show="$_submitting" style="display:none" <span data-show="$_submitting" style="display:none"
>{{ icons::spinner("h-4 w-4") }}</span >{{ icons::spinner("h-4 w-4") }}</span
> >
<span data-show="!$_submitting">{{ icons::check("h-4 w-4") }}</span>
Save Changes Save Changes
</button> </button>
<button
type="button"
onclick="history.back()"
class="inline-flex w-full items-center justify-center rounded-md border px-4 py-2 text-sm font-medium text-text-secondary transition hover:bg-surface-alt"
>
Cancel
</button>
</div> </div>
</form> </form>
</section> </section>

View file

@ -120,6 +120,60 @@
</image-upload> </image-upload>
{% endmacro %} {% endmacro %}
{% macro deferred_upload_with_preview(input_id, label, entity_type, entity_id, image_url) %}
<input type="hidden" name="image" id="{{ input_id }}" />
{% if let Some(url) = image_url %}
<div
id="{{ input_id }}-existing"
class="relative rounded-lg border bg-surface overflow-hidden"
>
<div
class="h-48 w-full bg-cover bg-center"
style="background-image: url('{{ url }}')"
role="img"
aria-label="{{ label }}"
></div>
<div class="absolute top-2 right-2 flex gap-1">
<image-upload
mode="deferred"
target-input="{{ input_id }}"
class="inline-flex items-center gap-1 rounded border bg-surface/90 px-2 py-1 text-xs font-medium text-accent hover:bg-surface cursor-pointer backdrop-blur-sm"
>
Replace
</image-upload>
<button
type="button"
onclick="if(confirm('Remove this image?')){fetch('/api/v1/{{ entity_type }}/{{ entity_id }}/image',{method:'DELETE'}).then(()=>{this.closest('[id$=-existing]').remove()})}"
class="inline-flex items-center gap-1 rounded border bg-surface/90 px-2 py-1 text-xs font-medium text-red-500 hover:bg-surface cursor-pointer backdrop-blur-sm"
>
Remove
</button>
</div>
</div>
{% else %}
<image-upload
mode="deferred"
target-input="{{ input_id }}"
class="flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-text-muted/30 bg-surface p-4 text-center text-text-muted cursor-pointer hover:border-accent/40 hover:text-text-secondary transition"
>
<svg
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z"
/>
</svg>
<span class="text-xs">{{ label }}</span>
</image-upload>
{% endif %}
{% endmacro %}
{% macro lightbox_script() %} {% macro lightbox_script() %}
<script> <script>
const openImageModal = (src) => { const openImageModal = (src) => {