Feature: Gassi-Hundefotos bei Teilnehmern + Fotos nach dem Treffen (SW by-v878)
This commit is contained in:
parent
b6a644ac3a
commit
44ba51cd38
8 changed files with 230 additions and 20 deletions
|
|
@ -2210,6 +2210,22 @@ def _migrate(conn_factory):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Gassi-Treffen Fotos
|
||||||
|
try:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS walk_photos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
walk_id INTEGER NOT NULL REFERENCES walks(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_walk_photos_walk ON walk_photos(walk_id)")
|
||||||
|
logger.info("Migration: walk_photos bereit.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Migration walk_photos: {e}")
|
||||||
|
|
||||||
# Versicherungs-Verwaltung
|
# Versicherungs-Verwaltung
|
||||||
try:
|
try:
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
|
|
|
||||||
|
|
@ -376,7 +376,7 @@ if STAGING and os.path.isdir(PROD_MEDIA_DIR):
|
||||||
else:
|
else:
|
||||||
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
|
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
|
||||||
|
|
||||||
APP_VER = "877" # muss mit APP_VER in app.js übereinstimmen
|
APP_VER = "878" # muss mit APP_VER in app.js übereinstimmen
|
||||||
|
|
||||||
@app.get("/.well-known/assetlinks.json")
|
@app.get("/.well-known/assetlinks.json")
|
||||||
async def assetlinks():
|
async def assetlinks():
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
"""BAN YARO — Gassi-Treffen"""
|
"""BAN YARO — Gassi-Treffen"""
|
||||||
|
|
||||||
import math
|
import math, os, uuid
|
||||||
import httpx
|
import httpx
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from database import db
|
from database import db
|
||||||
from auth import get_current_user
|
from auth import get_current_user
|
||||||
from routes.push import send_push_to_user
|
from routes.push import send_push_to_user
|
||||||
|
|
||||||
|
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -371,9 +373,34 @@ async def get_walk(walk_id: int):
|
||||||
GROUP BY wp.user_id
|
GROUP BY wp.user_id
|
||||||
""", (walk_id,)).fetchall()
|
""", (walk_id,)).fetchall()
|
||||||
|
|
||||||
|
# Hunde-Details (Foto + Rasse) pro Teilnehmer
|
||||||
|
dog_rows = conn.execute("""
|
||||||
|
SELECT wpd.user_id, d.name AS dog_name, d.foto_url, d.rasse
|
||||||
|
FROM walk_participant_dogs wpd
|
||||||
|
JOIN dogs d ON d.id = wpd.dog_id
|
||||||
|
WHERE wpd.walk_id = ?
|
||||||
|
""", (walk_id,)).fetchall()
|
||||||
|
|
||||||
|
# Walk-Fotos
|
||||||
|
photos = conn.execute(
|
||||||
|
"SELECT id, user_id, url, created_at FROM walk_photos WHERE walk_id=? ORDER BY created_at",
|
||||||
|
(walk_id,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
dogs_by_user = defaultdict(list)
|
||||||
|
for r in dog_rows:
|
||||||
|
dogs_by_user[r['user_id']].append({
|
||||||
|
'name': r['dog_name'], 'foto_url': r['foto_url'], 'rasse': r['rasse']
|
||||||
|
})
|
||||||
|
|
||||||
result = dict(walk)
|
result = dict(walk)
|
||||||
result['teilnehmer'] = [dict(p) for p in participants]
|
result['teilnehmer'] = [
|
||||||
|
{**dict(p), 'hunde_liste': dogs_by_user.get(p['user_id'], [])}
|
||||||
|
for p in participants
|
||||||
|
]
|
||||||
result['teilnehmer_count'] = len(result['teilnehmer'])
|
result['teilnehmer_count'] = len(result['teilnehmer'])
|
||||||
|
result['photos'] = [dict(p) for p in photos]
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -508,3 +535,82 @@ async def leave_walk(walk_id: int, user=Depends(get_current_user)):
|
||||||
conn.execute("UPDATE walks SET status = 'offen' WHERE id = ?", (walk_id,))
|
conn.execute("UPDATE walks SET status = 'offen' WHERE id = ?", (walk_id,))
|
||||||
|
|
||||||
return {"status": "left", "teilnehmer_count": count}
|
return {"status": "left", "teilnehmer_count": count}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# POST /api/walks/{id}/photos — Foto nach dem Treffen hochladen
|
||||||
|
# GET /api/walks/{id}/photos — Fotos eines Treffens abrufen
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
@router.post("/{walk_id}/photos", status_code=201)
|
||||||
|
async def upload_walk_photo(
|
||||||
|
walk_id: int,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
user=Depends(get_current_user)
|
||||||
|
):
|
||||||
|
with db() as conn:
|
||||||
|
walk = conn.execute("SELECT * FROM walks WHERE id=?", (walk_id,)).fetchone()
|
||||||
|
if not walk:
|
||||||
|
raise HTTPException(404, "Treffen nicht gefunden.")
|
||||||
|
# Nur Teilnehmer oder Veranstalter dürfen Fotos hochladen
|
||||||
|
is_participant = conn.execute(
|
||||||
|
"SELECT 1 FROM walk_participants WHERE walk_id=? AND user_id=?",
|
||||||
|
(walk_id, user['id'])
|
||||||
|
).fetchone()
|
||||||
|
if not is_participant and walk['user_id'] != user['id']:
|
||||||
|
raise HTTPException(403, "Nur Teilnehmer können Fotos hochladen.")
|
||||||
|
|
||||||
|
import io
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
try:
|
||||||
|
import pillow_heif; pillow_heif.register_heif_opener()
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raw = await file.read()
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(raw))
|
||||||
|
img = ImageOps.exif_transpose(img).convert("RGB")
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="JPEG", quality=88)
|
||||||
|
raw = buf.getvalue()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
filename = f"walk_{walk_id}_{uuid.uuid4().hex[:8]}.jpg"
|
||||||
|
path = os.path.join(MEDIA_DIR, "walks", filename)
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(raw)
|
||||||
|
|
||||||
|
url = f"/media/walks/{filename}"
|
||||||
|
with db() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO walk_photos (walk_id, user_id, url) VALUES (?,?,?)",
|
||||||
|
(walk_id, user['id'], url)
|
||||||
|
)
|
||||||
|
row = conn.execute("SELECT * FROM walk_photos WHERE id=?", (cur.lastrowid,)).fetchone()
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{walk_id}/photos")
|
||||||
|
async def get_walk_photos(walk_id: int):
|
||||||
|
with db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM walk_photos WHERE walk_id=? ORDER BY created_at",
|
||||||
|
(walk_id,)
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{walk_id}/photos/{photo_id}", status_code=204)
|
||||||
|
async def delete_walk_photo(walk_id: int, photo_id: int, user=Depends(get_current_user)):
|
||||||
|
with db() as conn:
|
||||||
|
photo = conn.execute(
|
||||||
|
"SELECT * FROM walk_photos WHERE id=? AND walk_id=?", (photo_id, walk_id)
|
||||||
|
).fetchone()
|
||||||
|
if not photo:
|
||||||
|
raise HTTPException(404)
|
||||||
|
walk = conn.execute("SELECT user_id FROM walks WHERE id=?", (walk_id,)).fetchone()
|
||||||
|
if photo['user_id'] != user['id'] and walk['user_id'] != user['id']:
|
||||||
|
raise HTTPException(403)
|
||||||
|
conn.execute("DELETE FROM walk_photos WHERE id=?", (photo_id,))
|
||||||
|
|
|
||||||
|
|
@ -101,9 +101,9 @@
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||||
<link rel="stylesheet" href="/css/design-system.css?v=877">
|
<link rel="stylesheet" href="/css/design-system.css?v=878">
|
||||||
<link rel="stylesheet" href="/css/layout.css?v=877">
|
<link rel="stylesheet" href="/css/layout.css?v=878">
|
||||||
<link rel="stylesheet" href="/css/components.css?v=877">
|
<link rel="stylesheet" href="/css/components.css?v=878">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|
@ -583,10 +583,10 @@
|
||||||
<div id="modal-container"></div>
|
<div id="modal-container"></div>
|
||||||
|
|
||||||
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
|
||||||
<script src="/js/api.js?v=877"></script>
|
<script src="/js/api.js?v=878"></script>
|
||||||
<script src="/js/ui.js?v=877"></script>
|
<script src="/js/ui.js?v=878"></script>
|
||||||
<script src="/js/app.js?v=877"></script>
|
<script src="/js/app.js?v=878"></script>
|
||||||
<script src="/js/worlds.js?v=877"></script>
|
<script src="/js/worlds.js?v=878"></script>
|
||||||
|
|
||||||
<!-- Feature-Seiten werden lazy geladen -->
|
<!-- Feature-Seiten werden lazy geladen -->
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,9 @@ const API = (() => {
|
||||||
invite(id, friendId) { return post(`/walks/${id}/invite`, { friend_id: friendId }); },
|
invite(id, friendId) { return post(`/walks/${id}/invite`, { friend_id: friendId }); },
|
||||||
rsvp(id, status) { return post(`/walks/${id}/rsvp`, { status }); },
|
rsvp(id, status) { return post(`/walks/${id}/rsvp`, { status }); },
|
||||||
participants(id) { return get(`/walks/${id}/participants`); },
|
participants(id) { return get(`/walks/${id}/participants`); },
|
||||||
|
photos(id) { return get(`/walks/${id}/photos`); },
|
||||||
|
uploadPhoto(id, formData) { return upload(`/walks/${id}/photos`, formData); },
|
||||||
|
deletePhoto(id, photoId) { return del(`/walks/${id}/photos/${photoId}`); },
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Router, State-Management, Navigation, Initialisierung.
|
Router, State-Management, Navigation, Initialisierung.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const APP_VER = '877'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
const APP_VER = '878'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||||
const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
|
const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt
|
||||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||||
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
|
// Cache-Bust-Parameter nach Update-Reload sofort entfernen
|
||||||
|
|
|
||||||
|
|
@ -393,14 +393,31 @@ window.Page_walks = (() => {
|
||||||
const isInvited = !!myRsvp;
|
const isInvited = !!myRsvp;
|
||||||
const invitations = participantData?.invitations ?? [];
|
const invitations = participantData?.invitations ?? [];
|
||||||
|
|
||||||
// Teilnehmerliste (join-Teilnehmer, klassisch)
|
// Teilnehmerliste mit Hundefotos
|
||||||
const teilnehmerHTML = walk.teilnehmer?.length
|
const teilnehmerHTML = walk.teilnehmer?.length
|
||||||
? walk.teilnehmer.map(t => `
|
? walk.teilnehmer.map(t => {
|
||||||
<div class="walks-participant">
|
const dogsHTML = (t.hunde_liste || []).map(d => {
|
||||||
<div class="walks-inv-avatar walks-inv-avatar--sm">${_avatarInitials(t.user_name)}</div>
|
const av = d.foto_url
|
||||||
<span class="walks-participant-name">${UI.escape(t.user_name)}</span>
|
? `<img src="${UI.escape(d.foto_url)}" alt="${UI.escape(d.name)}"
|
||||||
${t.hunde ? `<span class="walks-participant-hunde">${UI.icon('dog')} ${UI.escape(t.hunde)}</span>` : ''}
|
style="width:28px;height:28px;border-radius:50%;object-fit:cover;flex-shrink:0;border:1.5px solid var(--c-border)">`
|
||||||
</div>`).join('')
|
: `<div style="width:28px;height:28px;border-radius:50%;background:var(--c-surface-2);
|
||||||
|
display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1.5px solid var(--c-border)">
|
||||||
|
<svg class="ph-icon" style="width:14px;height:14px;color:var(--c-text-muted)" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>
|
||||||
|
</div>`;
|
||||||
|
return `<div style="display:flex;align-items:center;gap:4px">
|
||||||
|
${av}
|
||||||
|
<span style="font-size:var(--text-xs);color:var(--c-text-secondary)">${UI.escape(d.name)}${d.rasse ? ` · ${UI.escape(d.rasse)}` : ''}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
return `
|
||||||
|
<div class="walks-participant">
|
||||||
|
<div class="walks-inv-avatar walks-inv-avatar--sm">${_avatarInitials(t.user_name)}</div>
|
||||||
|
<div style="flex:1;min-width:0">
|
||||||
|
<div class="walks-participant-name">${UI.escape(t.user_name)}</div>
|
||||||
|
${dogsHTML ? `<div style="display:flex;flex-wrap:wrap;gap:var(--space-1);margin-top:4px">${dogsHTML}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}).join('')
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
// Einladungsliste
|
// Einladungsliste
|
||||||
|
|
@ -468,6 +485,31 @@ window.Page_walks = (() => {
|
||||||
<div id="wd-rating-${walk.id}"></div>
|
<div id="wd-rating-${walk.id}"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Fotos nach dem Treffen -->
|
||||||
|
<div class="walks-detail-section" id="wd-photos-section">
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--space-2)">
|
||||||
|
<div class="walks-detail-section-label" style="margin-bottom:0">${UI.icon('images')} Fotos</div>
|
||||||
|
${(isPast || _isToday(walk.datum)) && (isJoined || isOwn) ? `
|
||||||
|
<label style="cursor:pointer">
|
||||||
|
<input type="file" id="wd-photo-input" accept="image/*" style="display:none">
|
||||||
|
<span class="btn btn-secondary btn-sm">${UI.icon('camera')} Foto hinzufügen</span>
|
||||||
|
</label>` : ''}
|
||||||
|
</div>
|
||||||
|
<div id="wd-photos-grid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:4px;margin-top:var(--space-2)">
|
||||||
|
${(walk.photos || []).length === 0
|
||||||
|
? `<p style="grid-column:1/-1;color:var(--c-text-muted);font-size:var(--text-sm)">Noch keine Fotos.</p>`
|
||||||
|
: (walk.photos || []).map(p => `
|
||||||
|
<div style="position:relative;aspect-ratio:1">
|
||||||
|
<img src="${UI.escape(p.url)}" style="width:100%;height:100%;object-fit:cover;border-radius:var(--radius-sm);cursor:pointer"
|
||||||
|
onclick="UI.lightbox?.show?.([{ url:'${UI.escape(p.url)}' }], 0)">
|
||||||
|
${p.user_id === _appState.user?.id || isOwn ? `
|
||||||
|
<button type="button" class="wd-photo-del" data-photo-id="${p.id}"
|
||||||
|
style="position:absolute;top:3px;right:3px;background:rgba(0,0,0,.6);color:#fff;border:none;border-radius:50%;width:20px;height:20px;cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;padding:0">✕</button>
|
||||||
|
` : ''}
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)">
|
<p style="color:var(--c-text-muted);font-size:0.8rem;margin-top:var(--space-4)">
|
||||||
Veranstaltet von ${UI.escape(walk.veranstalter_name || 'Unbekannt')}
|
Veranstaltet von ${UI.escape(walk.veranstalter_name || 'Unbekannt')}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -525,6 +567,49 @@ window.Page_walks = (() => {
|
||||||
|
|
||||||
document.getElementById('wd-close')?.addEventListener('click', UI.modal.close);
|
document.getElementById('wd-close')?.addEventListener('click', UI.modal.close);
|
||||||
|
|
||||||
|
// Foto-Upload
|
||||||
|
document.getElementById('wd-photo-input')?.addEventListener('change', async function() {
|
||||||
|
if (!this.files.length) return;
|
||||||
|
const file = this.files[0];
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
try {
|
||||||
|
const photo = await API.walks.uploadPhoto(walk.id, formData);
|
||||||
|
const grid = document.getElementById('wd-photos-grid');
|
||||||
|
if (grid) {
|
||||||
|
grid.querySelector('p')?.remove();
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.style.cssText = 'position:relative;aspect-ratio:1';
|
||||||
|
div.innerHTML = `
|
||||||
|
<img src="${UI.escape(photo.url)}" style="width:100%;height:100%;object-fit:cover;border-radius:var(--radius-sm);cursor:pointer"
|
||||||
|
onclick="UI.lightbox?.show?.([{ url:'${UI.escape(photo.url)}' }], 0)">
|
||||||
|
<button type="button" class="wd-photo-del" data-photo-id="${photo.id}"
|
||||||
|
style="position:absolute;top:3px;right:3px;background:rgba(0,0,0,.6);color:#fff;border:none;border-radius:50%;width:20px;height:20px;cursor:pointer;font-size:11px;display:flex;align-items:center;justify-content:center;padding:0">✕</button>`;
|
||||||
|
grid.appendChild(div);
|
||||||
|
_bindPhotoDel(walk.id, div);
|
||||||
|
UI.toast.success('Foto hochgeladen.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
UI.toast.error(err.message || 'Fehler beim Hochladen.');
|
||||||
|
}
|
||||||
|
this.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Foto löschen — alle bestehenden Buttons
|
||||||
|
function _bindPhotoDel(walkId, container) {
|
||||||
|
container.querySelectorAll('.wd-photo-del').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
if (!window.confirm('Foto löschen?')) return;
|
||||||
|
try {
|
||||||
|
await API.walks.deletePhoto(walkId, parseInt(btn.dataset.photoId));
|
||||||
|
btn.closest('[style*="aspect-ratio"]')?.remove();
|
||||||
|
UI.toast.success('Foto gelöscht.');
|
||||||
|
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_bindPhotoDel(walk.id, document);
|
||||||
|
|
||||||
document.getElementById('wd-login')?.addEventListener('click', () => {
|
document.getElementById('wd-login')?.addEventListener('click', () => {
|
||||||
UI.modal.close();
|
UI.modal.close();
|
||||||
App.navigate('settings');
|
App.navigate('settings');
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
Offline-Cache + Push Notifications + Tile-Cache
|
Offline-Cache + Push Notifications + Tile-Cache
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
const CACHE_VERSION = 'by-v877';
|
const CACHE_VERSION = 'by-v878';
|
||||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue