merge: feat/gemini-filter-image-display

This commit is contained in:
2026-04-25 12:46:36 +02:00
12 changed files with 441 additions and 47 deletions

View File

@@ -1,11 +1,13 @@
package market
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -89,6 +91,13 @@ func (h *ResearchHandler) Research(c *gin.Context) {
return
}
// Nil out image URLs that don't return a successful HTTP response.
if raw.Felder.BildURL.Wert != nil && *raw.Felder.BildURL.Wert != "" {
if !imageURLReachable(ctx, *raw.Felder.BildURL.Wert) {
raw.Felder.BildURL.Wert = nil
}
}
result := toLLMResearchResult(raw, m)
c.JSON(http.StatusOK, gin.H{"data": result})
}
@@ -228,3 +237,20 @@ func toLLMResearchResult(raw llmOutput, m Market) ResearchResult {
Sources: sources,
}
}
func imageURLReachable(ctx context.Context, rawURL string) bool {
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodHead, rawURL, nil)
if err != nil {
return false
}
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; Marktvogt/1.0)")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false
}
_ = resp.Body.Close()
// 405 means HEAD not allowed but the resource exists.
return resp.StatusCode < 400 || resp.StatusCode == http.StatusMethodNotAllowed
}

View File

@@ -13,10 +13,15 @@ Extraktion aus Quellen.
suchen und Veranstalter-Website oeffnen. Fallback: Facebook-Event oder
Kalender (mittelalterkalender.info, marktkalendarium.de, mittelalterfeste.com,
mittelalter-termine.de).
2. **Zweitquelle pflicht**: verifiziere Datum + Ort gegen mindestens eine
2. **Jahr zuerst verifizieren**: Pruefe SOFORT ob die Seite Daten fuer das
Recherchejahr zeigt (Jahr aus recherche_datum). Viele Veranstalter-Websites
zeigen erst nach Ankuendigung aktuelle Daten - bis dahin ist die Vorjahresseite
noch sichtbar. Steht kein Jahr auf der Seite oder stimmt das Jahr nicht:
Zweitquelle oeffnen bevor du weiter extrahierst.
3. **Zweitquelle pflicht**: verifiziere Datum + Ort gegen mindestens eine
weitere Quelle. Schuetzt vor veralteten Daten auf schlecht gepflegten Seiten.
3. **Felder extrahieren** (siehe unten).
4. **status** auf Top-Level setzen:
4. **Felder extrahieren** (siehe unten).
5. **status** auf Top-Level setzen:
- "bestaetigt": ALLE Felder fuer Recherchejahr bestaetigt
- "unklar": Quellen widerspruechlich ODER einzelne Felder aus Vorjahr
- "vorjahr_unbestaetigt": ueberwiegend Vorjahresdaten
@@ -45,14 +50,21 @@ Extraktion aus Quellen.
- **land**: "Deutschland" | "Oesterreich" | "Schweiz".
- **veranstalter**: Verein, Firma oder Person. Impressum ist gute Quelle.
- **start_datum** / **end_datum**: YYYY-MM-DD, im Recherchejahr. Eintages-
Markt: beide gleich.
Markt: beide gleich. Pruefe das Jahr explizit - nicht nur Monat und Tag
uebernehmen. Wenn unklar ob Datum fuer Recherchejahr gilt: `wert: null` +
hinweis mit dem Jahr der Quelle.
- **oeffnungszeiten**: Array von Zeitfenstern {datum_von, datum_bis, von, bis}.
Nimm NUR explizit genannte Zeiten. Keine Zeiten fuer Tage ohne Angabe
erfinden. Bei Muster ueber mehrere Wochenenden (z.B. "Fr 17-02, Sa 16-00:30
an allen Wochenenden"): Muster anwenden, keine widersprechenden Eintraege
erzeugen. Vor Abgabe: KEINE Duplikate (gleiches Datum mehrfach). Format 24h
"HH:MM", nach Mitternacht "00:30"/"02:00".
Kompakt: identische Zeiten ueber mehrere Tage -> ein Eintrag mit Datumsbereich.
Nimm NUR explizit genannte Zeiten aus der aktuellen Veranstaltungsseite.
Wichtig: Zeiten sind jahresabhaengig und aendern sich haeufig. Nur dann
eintragen wenn die Quelle eindeutig das Recherchejahr adressiert (z.B. auf
der aktuellen Veranstaltungsseite oder im aktuellen FB-Event). Vorjahresdaten
oder allgemeine "typische Zeiten" -> `wert: null` + hinweis mit Quell-Jahr.
Keine Zeiten fuer Tage ohne Angabe erfinden. Bei Muster ueber mehrere
Wochenenden (z.B. "Fr 17-02, Sa 16-00:30 an allen Wochenenden"): Muster
anwenden, keine widersprechenden Eintraege erzeugen. Vor Abgabe: KEINE
Duplikate (gleiches Datum mehrfach). Format 24h "HH:MM", nach Mitternacht
"00:30"/"02:00". Kompakt: identische Zeiten ueber mehrere Tage -> ein
Eintrag mit Datumsbereich.
- **eintrittspreise**: Array {name, betrag, waehrung}. ALLE Kategorien
extrahieren wenn mehrere gelistet (Erwachsene, Kinder, Ermaessigt,
Familie, Gewandete, Abendkasse etc.), nicht nur eine.
@@ -60,6 +72,8 @@ Extraktion aus Quellen.
Gebuehren und sind NICHT der Eintrittspreis. Veranstalter-Website
bevorzugen. Nur Portal verfuegbar: extrahieren + hinweis "inkl.
Servicegebuehr". Eintritt frei: ein Eintrag name="Eintritt frei", betrag=0.
Preise aendern sich jaehrlich - nur Preise extrahieren die nachweislich fuer
das Recherchejahr gelten. Kein Nachweis -> `wert: null` + hinweis.
- **bild_url**: Offizielles Plakat/Banner/Header, kein Stockfoto, kein
Sponsor-Logo. Social-Media-Vorschaubilder ok. Nur URLs die du tatsaechlich
als src/og:image gesehen hast. Nichts findbar -> `null`.
@@ -83,6 +97,10 @@ Extraktion aus Quellen.
- Feld nicht findbar: `wert: null`, `quellen: []`, `extraktion: "direkt"`,
`hinweis` mit knapper Begruendung.
- NICHTS erfinden. Halluzinationen sind der teuerste Fehler.
- Jahreszugehoerigkeit: Jedes Datum, jede Zeit, jeden Preis vor der Ausgabe
auf das Recherchejahr pruefen. Steht "2025" auf der Quelle und das
Recherchejahr ist 2026: Wert auf null setzen, hinweis mit "Quelle zeigt
2025-Daten" eintragen.
- Widerspruch zwischen Quellen: Veranstalter-Website > Kalender > Social Media
> Presse. Widerspruch IMMER im hinweis dokumentieren, auch wenn die
offiziellste Quelle klar gewinnt. Format:

View File

@@ -12,14 +12,14 @@ import (
// AIStatus is the response payload for GET /admin/settings/ai.
type AIStatus struct {
Provider string `json:"provider"`
Connected bool `json:"connected"`
Model string `json:"model"`
Models []string `json:"models"`
APIKeyFingerprint string `json:"api_key_fingerprint,omitempty"`
GroundingEnabled bool `json:"grounding_enabled"`
GroundingQuota int `json:"grounding_quota"`
Usage UsageSummary `json:"usage"`
Provider string `json:"provider"`
Connected bool `json:"connected"`
Model string `json:"model"`
Models []ai.ModelInfo `json:"models"`
APIKeyFingerprint string `json:"api_key_fingerprint,omitempty"`
GroundingEnabled bool `json:"grounding_enabled"`
GroundingQuota int `json:"grounding_quota"`
Usage UsageSummary `json:"usage"`
}
type UsageSummary struct {
@@ -42,10 +42,10 @@ func NewHandler(provider *ai.GeminiProvider, store *Store, usageRepo *UsageRepo)
func (h *Handler) GetAI(c *gin.Context) {
ctx := c.Request.Context()
models, err := h.provider.ListModelNames(ctx)
models, err := h.provider.ListModels(ctx)
connected := err == nil
if models == nil {
models = []string{}
models = []ai.ModelInfo{}
}
// Fingerprint: last 4 chars of stored key (if any)
@@ -85,6 +85,20 @@ func (h *Handler) SetModel(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "model is required"})
return
}
// Validate against allowed list; degrade open if the list call fails (e.g. network blip).
if allowed, err := h.provider.ListModels(ctx); err == nil {
found := false
for _, m := range allowed {
if m.Name == req.Model {
found = true
break
}
}
if !found {
c.JSON(http.StatusBadRequest, gin.H{"error": "model not in allowed list"})
return
}
}
userID := callerID(c)
if err := h.store.SetModel(ctx, req.Model, userID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save model"})

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"time"
@@ -11,6 +12,95 @@ import (
"google.golang.org/genai"
)
// ModelInfo describes a Gemini model available for use.
type ModelInfo struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Stable bool `json:"stable"`
Thinking bool `json:"thinking"`
InputTokenLimit int32 `json:"input_token_limit"`
InputUSDPerM float64 `json:"input_usd_per_m"`
OutputUSDPerM float64 `json:"output_usd_per_m"`
}
// geminiPricing maps model name prefixes to $/1M token rates (≤200k tier).
// Source: https://ai.google.dev/gemini-api/docs/pricing — update manually when prices change.
var geminiPricing = map[string]struct{ in, out float64 }{
"gemini-3.1-pro": {2.00, 12.00},
"gemini-3.1-flash-lite": {0.25, 1.50},
"gemini-3-flash": {0.50, 3.00},
"gemini-2.5-pro": {1.25, 10.00},
"gemini-2.5-flash-lite": {0.10, 0.40},
"gemini-2.5-flash": {0.30, 2.50},
}
// priceFor returns the $/1M input and output token cost for the given model name.
// Uses longest-prefix match against geminiPricing; returns (0, 0) if unknown.
func priceFor(name string) (in, out float64) {
best := ""
for prefix, p := range geminiPricing {
if strings.HasPrefix(name, prefix) && len(prefix) > len(best) {
best = prefix
in, out = p.in, p.out
}
}
return
}
// filterCompatibleModels selects models that work with our request shape:
// generateContent + systemInstruction + responseSchema + optional googleSearchRetrieval.
//
// We rely on name-based filtering rather than SupportedActions because the Gemini
// public API omits supportedGenerationMethods for stable text models, leaving
// SupportedActions empty even for fully compatible models like gemini-2.5-flash.
func filterCompatibleModels(items []*genai.Model) []ModelInfo {
blockedSubstrings := []string{
"-tts", "-image", "-native-audio", "-live",
"-computer-use", "-robotics", "-embedding",
}
out := make([]ModelInfo, 0, len(items))
for _, m := range items {
if m.TunedModelInfo != nil {
continue
}
name := strings.TrimPrefix(m.Name, "models/")
if !strings.HasPrefix(name, "gemini-") {
continue
}
if strings.HasPrefix(name, "gemini-2.0-") {
continue
}
blocked := false
for _, sub := range blockedSubstrings {
if strings.Contains(name, sub) {
blocked = true
break
}
}
if blocked {
continue
}
in, outP := priceFor(name)
out = append(out, ModelInfo{
Name: name,
DisplayName: m.DisplayName,
Stable: !strings.Contains(name, "-preview"),
Thinking: m.Thinking,
InputTokenLimit: m.InputTokenLimit,
InputUSDPerM: in,
OutputUSDPerM: outP,
})
}
// Stable models first; within each group sort by name descending (newer families first).
sort.Slice(out, func(i, j int) bool {
if out[i].Stable != out[j].Stable {
return out[i].Stable
}
return out[i].Name > out[j].Name
})
return out
}
// Gemini API pricing (as of 2026-04). Refresh constants when pricing changes.
// https://ai.google.dev/gemini-api/docs/pricing
const (
@@ -91,7 +181,7 @@ func (p *GeminiProvider) SetModel(model string) {
p.model = model
}
func (p *GeminiProvider) ListModelNames(ctx context.Context) ([]string, error) {
func (p *GeminiProvider) ListModels(ctx context.Context) ([]ModelInfo, error) {
p.mu.RLock()
client := p.client
p.mu.RUnlock()
@@ -102,16 +192,7 @@ func (p *GeminiProvider) ListModelNames(ctx context.Context) ([]string, error) {
if err != nil {
return nil, fmt.Errorf("gemini: list models: %w", err)
}
var names []string
for _, m := range resp.Items {
for _, action := range m.SupportedActions {
if action == "generateContent" {
names = append(names, strings.TrimPrefix(m.Name, "models/"))
break
}
}
}
return names, nil
return filterCompatibleModels(resp.Items), nil
}
func (p *GeminiProvider) Chat(ctx context.Context, req *ChatRequest) (*ChatResponse, error) {

View File

@@ -0,0 +1,170 @@
package ai
import (
"strings"
"testing"
"google.golang.org/genai"
)
func makeModel(name string, tuned, thinking bool) *genai.Model {
m := &genai.Model{
Name: "models/" + name,
DisplayName: name,
Thinking: thinking,
InputTokenLimit: 32000,
}
if tuned {
m.TunedModelInfo = &genai.TunedModelInfo{}
}
return m
}
func TestFilterCompatibleModels_KeepsGeminiTextFamilies(t *testing.T) {
// SupportedActions intentionally nil — stable text models don't have it populated.
kept := []*genai.Model{
makeModel("gemini-2.5-pro", false, true),
makeModel("gemini-2.5-flash", false, false),
makeModel("gemini-2.5-flash-lite", false, false),
makeModel("gemini-3-flash-preview-04-2026", false, false),
makeModel("gemini-3.1-pro-preview-04-2026", false, true),
makeModel("gemini-3.1-flash-lite-preview-04-2026", false, false),
}
got := filterCompatibleModels(kept)
if len(got) != len(kept) {
t.Errorf("want %d models, got %d: %v", len(kept), len(got), modelNames(got))
}
}
func TestFilterCompatibleModels_DropsExcludedFamilies(t *testing.T) {
// SupportedActions is intentionally ignored — the Gemini public API omits
// supportedGenerationMethods for stable text models. Name-based filtering is
// the reliable gate; these cases verify every blocked category by name.
cases := []struct {
name string
model *genai.Model
}{
{"tts", makeModel("gemini-2.5-flash-preview-tts", false, false)},
{"pro tts", makeModel("gemini-2.5-pro-preview-tts", false, false)},
{"image", makeModel("gemini-2.5-flash-image", false, false)},
{"native audio", makeModel("gemini-2.5-flash-native-audio-preview-12-2025", false, false)},
{"live", makeModel("gemini-2.5-flash-live-preview", false, false)},
{"computer use", makeModel("gemini-2.5-computer-use-preview-10-2025", false, false)},
{"robotics", makeModel("gemini-robotics-er-1.6-preview", false, false)},
{"embedding", makeModel("gemini-embedding-001", false, false)},
{"gemma", makeModel("gemma-3-27b-it", false, false)},
{"gemma nano", makeModel("gemma-3n-e4b-it", false, false)},
{"deep research", makeModel("deep-research-preview-04-2026", false, false)},
{"deep research max", makeModel("deep-research-max-preview-04-2026", false, false)},
{"imagen", makeModel("imagen-3.0-generate-001", false, false)},
{"veo", makeModel("veo-3.1-generate-preview", false, false)},
{"lyria", makeModel("lyria-realtime-exp", false, false)},
{"learnlm", makeModel("learnlm-2.0-flash-experimental", false, false)},
{"gemini 2.0 flash eol", makeModel("gemini-2.0-flash", false, false)},
{"gemini 2.0 flash lite eol", makeModel("gemini-2.0-flash-lite", false, false)},
{"tuned model", makeModel("gemini-2.5-flash", true, false)},
}
for _, tc := range cases {
got := filterCompatibleModels([]*genai.Model{tc.model})
if len(got) != 0 {
t.Errorf("case %q: want 0 models, got %d: %v", tc.name, len(got), modelNames(got))
}
}
}
func TestFilterCompatibleModels_StableField(t *testing.T) {
items := []*genai.Model{
makeModel("gemini-2.5-flash", false, false),
makeModel("gemini-3-flash-preview-04-2026", false, false),
}
got := filterCompatibleModels(items)
if len(got) != 2 {
t.Fatalf("want 2, got %d", len(got))
}
for _, m := range got {
expectStable := !strings.Contains(m.Name, "-preview")
if m.Stable != expectStable {
t.Errorf("model %q: Stable=%v, want %v", m.Name, m.Stable, expectStable)
}
}
}
func TestFilterCompatibleModels_ThinkingField(t *testing.T) {
items := []*genai.Model{
makeModel("gemini-2.5-pro", false, true),
makeModel("gemini-2.5-flash", false, false),
}
got := filterCompatibleModels(items)
if len(got) != 2 {
t.Fatalf("want 2, got %d", len(got))
}
// find by name
for _, m := range got {
if m.Name == "gemini-2.5-pro" && !m.Thinking {
t.Errorf("gemini-2.5-pro: want Thinking=true")
}
if m.Name == "gemini-2.5-flash" && m.Thinking {
t.Errorf("gemini-2.5-flash: want Thinking=false")
}
}
}
func TestFilterCompatibleModels_SortStableFirst(t *testing.T) {
items := []*genai.Model{
makeModel("gemini-3-flash-preview-04-2026", false, false),
makeModel("gemini-2.5-pro", false, true),
makeModel("gemini-3.1-pro-preview-04-2026", false, true),
makeModel("gemini-2.5-flash-lite", false, false),
}
got := filterCompatibleModels(items)
if len(got) != 4 {
t.Fatalf("want 4, got %d", len(got))
}
// First two must be stable
if !got[0].Stable || !got[1].Stable {
t.Errorf("first two should be stable, got %v %v", got[0].Name, got[1].Name)
}
// Last two must be preview
if got[2].Stable || got[3].Stable {
t.Errorf("last two should be preview, got %v %v", got[2].Name, got[3].Name)
}
}
func TestPriceFor_KnownFamilies(t *testing.T) {
cases := []struct {
name string
wantIn float64
wantOut float64
}{
{"gemini-2.5-flash-lite", 0.10, 0.40},
{"gemini-2.5-flash-lite-preview-05-2026", 0.10, 0.40},
{"gemini-2.5-flash", 0.30, 2.50},
{"gemini-2.5-flash-preview-04-2026", 0.30, 2.50},
{"gemini-2.5-pro", 1.25, 10.00},
{"gemini-2.5-pro-preview-06-2026", 1.25, 10.00},
{"gemini-3-flash-preview-04-2026", 0.50, 3.00},
{"gemini-3.1-pro-preview-04-2026", 2.00, 12.00},
{"gemini-3.1-flash-lite-preview-04-2026", 0.25, 1.50},
}
for _, tc := range cases {
in, out := priceFor(tc.name)
if in != tc.wantIn || out != tc.wantOut {
t.Errorf("priceFor(%q): got (%v, %v), want (%v, %v)", tc.name, in, out, tc.wantIn, tc.wantOut)
}
}
}
func TestPriceFor_UnknownReturnsZero(t *testing.T) {
in, out := priceFor("gemini-99-experimental-unknown")
if in != 0 || out != 0 {
t.Errorf("want (0, 0), got (%v, %v)", in, out)
}
}
func modelNames(ms []ModelInfo) []string {
names := make([]string, len(ms))
for i, m := range ms {
names[i] = m.Name
}
return names
}

View File

@@ -16,7 +16,7 @@ type Provider interface {
type ModelSelector interface {
Model() string
SetModel(string)
ListModelNames(ctx context.Context) ([]string, error)
ListModels(ctx context.Context) ([]ModelInfo, error)
BaseURL() string
}

View File

@@ -206,11 +206,21 @@ export interface AIUsageEvent {
error?: string;
}
export interface AIModelInfo {
name: string;
display_name: string;
stable: boolean;
thinking: boolean;
input_token_limit: number;
input_usd_per_m: number;
output_usd_per_m: number;
}
export interface AIStatus {
provider: string;
connected: boolean;
model: string;
models: string[];
models: AIModelInfo[];
api_key_fingerprint?: string;
grounding_enabled: boolean;
grounding_quota: number;

View File

@@ -477,7 +477,27 @@
placeholder={mode === 'public' ? 'Name des Veranstalters' : ''}
/>
<Input label="Bild-URL" name="image_url" type="url" value={imageUrl} />
<Input
label="Bild-URL"
name="image_url"
type="url"
value={imageUrl}
oninput={(e) => {
imageUrl = e.currentTarget.value;
}}
/>
{#if imageUrl}
<div class="mt-2">
<img
src={imageUrl}
alt="Bildvorschau"
class="max-h-48 rounded-lg object-contain"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
</div>
{/if}
</fieldset>
<fieldset class="space-y-4">
@@ -504,7 +524,9 @@
</div>
<Input
label="Von"
type="time"
type="text"
pattern="([01][0-9]|2[0-3]):[0-5][0-9]"
placeholder="HH:MM"
value={row.open}
oninput={(e) => {
row.open = e.currentTarget.value;
@@ -512,7 +534,9 @@
/>
<Input
label="Bis"
type="time"
type="text"
pattern="([01][0-9]|2[0-3]):[0-5][0-9]"
placeholder="HH:MM"
value={row.close}
oninput={(e) => {
row.close = e.currentTarget.value;

View File

@@ -6,6 +6,7 @@
}
let { market }: Props = $props();
let imgFailed = $state(false);
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('de-DE', {
@@ -25,14 +26,24 @@
href="/markt/{market.slug}"
class="group bg-vellum block rounded-lg border border-stone-200 shadow-sm transition-shadow hover:shadow-md dark:border-stone-700"
>
{#if market.image_url}
<div class="aspect-[16/9] overflow-hidden rounded-t-lg">
<img
src={market.image_url}
alt={market.name}
class="h-full w-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
{#if market.image_url && !imgFailed}
<img
src={market.image_url}
alt={market.name}
class="w-full rounded-t-lg"
style="padding: 16px 16px 0; max-height: 150px; object-fit: contain;"
loading="lazy"
onerror={() => {
imgFailed = true;
}}
/>
{:else}
<div
class="flex h-[150px] items-center justify-center rounded-t-lg bg-gradient-to-br from-stone-800 to-stone-900 dark:from-stone-900 dark:to-stone-950"
>
<span class="text-5xl font-bold text-stone-600 uppercase select-none dark:text-stone-700">
{market.city.charAt(0)}
</span>
</div>
{/if}
<div class="p-4">

View File

@@ -183,7 +183,11 @@
class="focus:border-primary-500 focus:ring-primary-500 rounded-md border border-stone-300 bg-white px-3 py-2 text-sm text-stone-900 shadow-sm focus:ring-1 focus:outline-none dark:border-stone-600 dark:bg-stone-800 dark:text-stone-100"
>
{#each data.ai.models as model}
<option value={model}>{model}</option>
<option value={model.name}>
{model.display_name || model.name}{model.input_usd_per_m > 0
? ` — $${model.input_usd_per_m.toFixed(2)} / $${model.output_usd_per_m.toFixed(2)} per 1M`
: ''}{!model.stable ? ' (Preview)' : ''}
</option>
{/each}
</select>
<button

View File

@@ -302,6 +302,18 @@
<!-- Market details -->
<div class="rounded-lg border border-stone-200 p-6 dark:border-stone-700">
<h2 class="mb-4 text-lg font-semibold">Details</h2>
{#if data.market.image_url}
<div class="mb-4">
<img
src={data.market.image_url}
alt={data.market.name}
class="max-h-48 rounded-lg object-contain"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
</div>
{/if}
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Beschreibung</dt>
@@ -359,6 +371,21 @@
{data.market.slug}
</dd>
</div>
{#if data.market.image_url}
<div class="sm:col-span-2">
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Bild-URL</dt>
<dd class="mt-1 text-sm break-all text-stone-900 dark:text-stone-100">
<a
href={data.market.image_url}
target="_blank"
rel="noopener noreferrer"
class="text-primary-600 dark:text-primary-400 hover:underline"
>
{data.market.image_url}
</a>
</dd>
</div>
{/if}
</dl>
</div>

View File

@@ -253,8 +253,17 @@
</nav>
{#if market.image_url}
<div class="mb-8 overflow-hidden rounded-lg">
<img src={market.image_url} alt={market.name} class="h-64 w-full object-cover sm:h-80" />
<div class="mb-8 rounded-lg">
<img
src={market.image_url}
alt={market.name}
class="w-full rounded-lg"
style="object-fit: contain; max-height: 250px;"
onerror={(e) => {
const wrap = e.currentTarget.parentElement;
if (wrap) wrap.style.display = 'none';
}}
/>
</div>
{/if}