Initial my2dos Django app

This commit is contained in:
rucki
2026-08-22 13:45:40 +02:00
parent cb1931ce60
commit 4e542c2b8e
32 changed files with 1070 additions and 0 deletions
@@ -0,0 +1,2 @@
{% include 'core/_item.html' %}
{% include 'core/_tags.html' with tags=tags oob=True %}
+25
View File
@@ -0,0 +1,25 @@
<form id="item-{{ item.id }}" class="card item-card shadow-sm position-relative" method="post" enctype="multipart/form-data" hx-post="{% url 'edit_item' item.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-target="#item-{{ item.id }}" hx-swap="outerHTML" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])" x-init="text=$el.querySelector('textarea').value">
{% csrf_token %}
<div class="card-body vstack gap-2">
<div class="small text-muted">Typ: {{ item.get_kind_display }}</div>
<textarea class="form-control" name="content" rows="5" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.content }}</textarea>
<textarea class="form-control" name="comment" rows="3" placeholder="Kommentar, z.B. gleiche Aufgabe bereits erfasst [[12]]" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.comment }}</textarea>
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></template>
</div>
<div class="row g-2">
<div class="col-md-4"><select class="form-select" name="visibility">
<option value="private" {% if item.visibility == 'private' %}selected{% endif %}>Privat</option>
<option value="public" {% if item.visibility == 'public' %}selected{% endif %}>Öffentlich</option>
</select></div>
{% if item.kind == 'todo' %}<div class="col-md-4"><input class="form-control" type="datetime-local" name="due_at" value="{{ item.due_at|date:'Y-m-d\\TH:i' }}" title="Datum/Zeit für Todo"></div>{% endif %}
<div class="col-md-4"><input class="form-control" type="file" name="files" x-ref="fileInput" @change="previewFile($event)"></div>
</div>
{% if item.kind == 'link' %}<input class="form-control" type="url" name="url" value="{{ item.url }}" placeholder="URL">{% endif %}
<template x-if="preview"><div class="small"><span class="text-muted">Bild aus Zwischenablage:</span><br><img :src="preview" class="img-thumbnail mt-1" style="max-height:160px"></div></template>
<div class="d-flex gap-2">
<button class="btn btn-dark btn-sm">Speichern</button>
<button type="button" class="btn btn-outline-secondary btn-sm" hx-get="{% url 'item_card' item.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-target="#item-{{ item.id }}" hx-swap="outerHTML">Abbrechen</button>
</div>
</div>
</form>
+31
View File
@@ -0,0 +1,31 @@
{% load markdown_extras %}
<article id="item-{{ item.id }}" class="card item-card shadow-sm {% if item.is_done %}done{% endif %}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-3">
<div class="flex-grow-1">
<div class="mb-2 d-flex flex-wrap gap-2 align-items-center">
<span class="badge {% if item.kind == 'todo' %}text-bg-primary{% elif item.kind == 'link' %}text-bg-success{% else %}text-bg-secondary{% endif %}">{{ item.get_kind_display }}</span>
<span class="badge text-bg-light">{{ item.get_visibility_display }}</span>
<a class="text-muted small" href="{{ item.get_absolute_url }}">#{{ item.id }}</a>
{% for tag in item.tags.all %}<a class="badge rounded-pill text-bg-light text-decoration-none tag" href="/?tag={{ tag.name }}">#{{ tag.name }}</a>{% endfor %}
</div>
<div class="content">{{ item.content|markdown }}</div>
{% if item.comment %}<div class="border-start ps-2 mt-2 small content text-muted">{{ item.comment|markdown }}</div>{% endif %}
{% if item.due_at %}<div class="small text-warning-emphasis mt-2">Fällig: {{ item.due_at|date:'d.m.Y H:i' }}</div>{% endif %}
{% if item.url %}<a href="{{ item.url }}" target="_blank" rel="noopener">{{ item.url }}</a>{% endif %}
{% if item.linked_items.all %}<div class="small mt-2">Links: {% for linked in item.linked_items.all %}<a href="{{ linked.get_absolute_url }}">#{{ linked.id }}</a> {% endfor %}</div>{% endif %}
{% if item.attachments.all %}
<div class="small mt-2">Anhänge: {% for a in item.attachments.all %}<a href="{{ a.file.url }}">{{ a.file.name }}</a> {% endfor %}</div>
<div class="d-flex flex-wrap gap-2 mt-2">
{% for a in item.attachments.all %}{% if a.file.name|is_image %}<a href="{{ a.file.url }}" target="_blank"><img src="{{ a.file.url }}" class="img-thumbnail" style="max-width:220px;max-height:180px" alt="{{ a.file.name }}"></a>{% endif %}{% endfor %}
</div>
{% endif %}
</div>
<div class="btn-group btn-group-sm">
{% if item.kind == 'todo' %}<button class="btn btn-outline-primary" hx-post="{% url 'toggle_done' item.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-target="#item-{{ item.id }}" hx-swap="outerHTML">✓</button>{% endif %}
<button class="btn btn-outline-secondary" hx-get="{% url 'edit_item' item.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-target="#item-{{ item.id }}" hx-swap="outerHTML">✎</button>
<button class="btn btn-outline-danger" hx-post="{% url 'delete_item' item.id %}" hx-target="#item-{{ item.id }}" hx-swap="delete">×</button>
</div>
</div>
</div>
</article>
+10
View File
@@ -0,0 +1,10 @@
{% load querystring %}
<div id="tag-list" class="d-flex flex-wrap gap-2"{% if oob %} hx-swap-oob="true"{% endif %}>
{% for tag in tags %}
{% if active_tag == tag.name %}
<a class="badge rounded-pill text-bg-dark text-decoration-none tag" href="{% query_replace tag=None %}">#{{ tag.name }} ×</a>
{% else %}
<a class="badge rounded-pill text-bg-light text-decoration-none tag" href="{% query_replace tag=tag.name %}">#{{ tag.name }}</a>
{% endif %}
{% empty %}<span class="text-muted small">Noch keine Tags</span>{% endfor %}
</div>
+37
View File
@@ -0,0 +1,37 @@
{% load static %}
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>my2dos</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
body{background:#f7f7f4}.shell{max-width:960px}.item-card{border:0;border-radius:1rem}.content p:last-child{margin-bottom:0}.tag{font-size:.85rem}.slash-menu{position:absolute;z-index:20;top:100%;left:0;width:18rem}.done{opacity:.6}.done .content{text-decoration:line-through}
</style>
</head>
<body>
<nav class="navbar bg-white border-bottom sticky-top"><div class="container shell"><a class="navbar-brand fw-bold" href="/">my2dos</a><div class="d-flex align-items-center gap-3"><span class="text-muted small d-none d-sm-inline">Notizen · Aufgaben · Links</span>{% if user.is_authenticated %}<a class="small" href="{% url 'settings' %}">{{ user.get_full_name|default:user.username }}</a><form method="post" action="{% url 'logout' %}" class="m-0">{% csrf_token %}<button class="btn btn-sm btn-outline-secondary">Logout</button></form>{% else %}<a class="btn btn-sm btn-dark" href="{% url 'login' %}">Login</a>{% endif %}</div></div></nav>
<main class="container shell py-4">{% for message in messages %}<div class="alert alert-{{ message.tags|default:'info' }}">{{ message }}</div>{% endfor %}{% block content %}{% endblock %}</main>
<script>
document.body.addEventListener('htmx:configRequest', e => {e.detail.headers['X-CSRFToken']='{{ csrf_token }}'});
window.quickComposer = window.quickComposer || function(tags){
return {
open:false,text:'',active:0,query:'',currentEl:null,preview:null,linkSuggestions:[],tags:tags||[],
init(){document.body.addEventListener('tagsChanged',()=>{fetch('/api/tags/').then(r=>r.json()).then(d=>{this.tags=d.tags||[]})})},
commands:[{token:'/todo',label:'Aufgabe erstellen'},{token:'/link',label:'Link speichern'},{token:'/public',label:'öffentlich'},{token:'/private',label:'privat'},{token:'/code',label:'Markdown Codeblock einfügen'},{token:'[[',label:'interne Aufgabe/Notiz suchen'}],
get filtered(){let q=this.query.toLowerCase();let list=q.startsWith('#')?this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q)).map(t=>({token:'#'+t,label:'Tag'})):q.startsWith('[[')?this.linkSuggestions.map(i=>({token:`[[${i.id}]]`,label:`${i.kind}: ${i.label}`})):this.commands.filter(c=>!q||c.token.toLowerCase().startsWith(q));return list.length?list:[{token:this.query,label:'neu'}]},
onInput(e){this.update(e.target)},
handlePaste(e){let item=[...(e.clipboardData?.items||[])].find(i=>i.kind==='file'&&i.type.startsWith('image/'));if(!item||!this.$refs.fileInput)return;let file=item.getAsFile();let ext=(file.type.split('/')[1]||'png').replace('jpeg','jpg');let named=new File([file],`paste-${new Date().toISOString().replace(/[:.]/g,'-')}.${ext}`,{type:file.type});let dt=new DataTransfer();dt.items.add(named);this.$refs.fileInput.files=dt.files;this.preview=URL.createObjectURL(named)},
previewFile(e){let file=e.target.files[0];this.preview=file&&file.type.startsWith('image/')?URL.createObjectURL(file):null},
onKeydown(e){this.currentEl=e.target;if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){e.preventDefault();this.open=false;e.target.form.requestSubmit();return}if(this.open&&e.key==='ArrowDown'){e.preventDefault();this.active=(this.active+1)%this.filtered.length;return}if(this.open&&e.key==='ArrowUp'){e.preventDefault();this.active=(this.active-1+this.filtered.length)%this.filtered.length;return}if(this.open&&(e.key==='Enter'||e.key==='Tab')&&!e.shiftKey){e.preventDefault();this.insert(this.filtered[this.active].token);return}if(e.key==='Escape'){this.open=false;return}if(e.key===' '){this.open=false;return}setTimeout(()=>this.update(e.target),0)},
update(el){this.currentEl=el;this.text=el.value;let before=el.value.slice(0,el.selectionStart);let fragment=(before.match(/(^|\s)([^\s]*)$/)||['','',''])[2];this.query=fragment;this.active=0;this.open=fragment.startsWith('/')||fragment.startsWith('#')||fragment.startsWith('[[');if(fragment.startsWith('[[')){fetch(`/api/item-suggestions/?q=${encodeURIComponent(fragment.slice(2))}`).then(r=>r.json()).then(d=>{this.linkSuggestions=d.items||[]})}},
insert(token){let el=this.currentEl||this.$el.querySelector('textarea');if(!el)return;if(token==='/code')token='```\n\n```';let pos=el.selectionStart;let before=el.value.slice(0,pos);let after=el.value.slice(pos);let match=before.match(/(^|\s)([^\s]*)$/);let start=match?pos-match[2].length:pos;let prefix=start>0&&el.value[start-1]!==' '?' ':'';let value=el.value.slice(0,start)+prefix+token+' '+after;this.text=value;el.value=value;this.open=false;this.$nextTick(()=>{let p=start+prefix.length+token.length+1;el.focus();el.setSelectionRange(p,p)})}
}
};
window.searchBox = function(tags){return{open:false,active:0,q:'',tags:tags||[],get filtered(){let q=this.q.toLowerCase();return this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q))},onInput(e){this.q=e.target.value;this.open=this.q.startsWith('#')},onKeydown(e){if(!this.open)return;if(e.key==='ArrowDown'){e.preventDefault();this.active=(this.active+1)%Math.max(this.filtered.length,1)}else if(e.key==='ArrowUp'){e.preventDefault();this.active=(this.active-1+Math.max(this.filtered.length,1))%Math.max(this.filtered.length,1)}else if(e.key==='Enter'||e.key==='Tab'){if(this.filtered.length){e.preventDefault();this.select(this.filtered[this.active])}}else if(e.key==='Escape'){this.open=false}},select(tag){let url=new URL(window.location.href);url.searchParams.set('tag',tag);url.searchParams.delete('q');window.location.href=url.pathname+'?'+url.searchParams.toString()}}}
</script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{% extends 'core/base.html' %}
{% block content %}
<a class="btn btn-sm btn-outline-secondary mb-3" href="/">← zurück</a>
{% include 'core/_item.html' %}
{% if item.backlinks.all %}
<div class="mt-4"><h5>Backlinks</h5><div class="vstack gap-3">{% for item in item.backlinks.all %}{% include 'core/_item.html' %}{% endfor %}</div></div>
{% endif %}
{% endblock %}
+170
View File
@@ -0,0 +1,170 @@
{% extends 'core/base.html' %}
{% load querystring %}
{% block content %}
<div class="row g-4">
<aside class="col-lg-3 order-lg-2">
<div class="card item-card shadow-sm"><div class="card-body">
<div class="fw-semibold mb-2">Filter</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<a class="btn btn-sm {% if not active_kind and not active_status %}btn-dark{% else %}btn-outline-dark{% endif %}" href="{% query_replace kind=None status=None %}">Alle</a>
<a class="btn btn-sm {% if active_kind == 'todo' %}btn-primary{% else %}btn-outline-primary{% endif %}" href="{% query_replace kind='todo' %}">Todos</a>
<a class="btn btn-sm {% if active_kind == 'note' %}btn-secondary{% else %}btn-outline-secondary{% endif %}" href="{% query_replace kind='note' %}">Notizen</a>
<a class="btn btn-sm {% if active_kind == 'link' %}btn-success{% else %}btn-outline-success{% endif %}" href="{% query_replace kind='link' %}">Links</a>
<a class="btn btn-sm {% if active_status == 'open' %}btn-warning{% else %}btn-outline-warning{% endif %}" href="{% if active_status == 'open' %}{% query_replace status=None %}{% else %}{% query_replace status='open' %}{% endif %}">Unerledigt{% if active_status == 'open' %} ×{% endif %}</a>
<a class="btn btn-sm {% if active_status == 'archive' %}btn-dark{% else %}btn-outline-dark{% endif %}" href="{% if active_status == 'archive' %}{% query_replace status=None %}{% else %}{% query_replace status='archive' %}{% endif %}">Archiv{% if active_status == 'archive' %} ×{% endif %}</a>
</div>
<form class="mb-3 position-relative" method="get" x-data="searchBox([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])" x-on:keydown.window="if(($event.ctrlKey||$event.metaKey)&&$event.key==='f'){ $event.preventDefault(); $refs.search.focus(); $refs.search.select(); }">
{% if active_tag %}<input type="hidden" name="tag" value="{{ active_tag }}">{% endif %}
{% if active_kind %}<input type="hidden" name="kind" value="{{ active_kind }}">{% endif %}
{% if active_status %}<input type="hidden" name="status" value="{{ active_status }}">{% endif %}
<input x-ref="search" class="form-control form-control-sm" name="q" value="{{ q }}" placeholder="Suche (Ctrl+F, # für Tags)" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" @input="onInput($event)" @keydown="onKeydown($event)">
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<template x-for="(tag, idx) in filtered" :key="tag"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="select(tag)"><strong x-text="'#'+tag"></strong></button></template>
</div>
</form>
<div class="fw-semibold mb-2">Tags</div>
<div hx-get="{% url 'tag_list' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-trigger="tagsChanged from:body" hx-target="#tag-list" hx-swap="outerHTML">
{% include 'core/_tags.html' %}
</div>
</div></div>
</aside>
<section class="col-lg-9">
<form class="position-relative mb-4" method="post" enctype="multipart/form-data" hx-post="{{ request.get_full_path }}" hx-target="#items" hx-swap="afterbegin" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])" x-on:htmx:after-request="if($event.detail.successful){text='';open=false;$el.reset()}">
{% csrf_token %}
{{ form.content }}
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></template>
</div>
<div class="d-flex align-items-center gap-2 mt-2">
<input class="form-control" type="file" name="files" x-ref="fileInput" @change="previewFile($event)">
<button class="btn btn-dark px-4">Speichern</button>
</div>
<template x-if="preview"><div class="mt-2 small"><span class="text-muted">Bild aus Zwischenablage:</span><br><img :src="preview" class="img-thumbnail mt-1" style="max-height:160px"></div></template>
</form>
<div id="items" class="vstack gap-3">
{% for item in items %}{% include 'core/_item.html' %}{% empty %}<div class="text-center text-muted py-5">Noch nichts vorhanden.</div>{% endfor %}
</div>
</section>
</div>
<script>
function quickComposer(tags){
return {
open:false,
text:'',
active:0,
query:'',
currentEl:null,
preview:null,
linkSuggestions:[],
tags:tags||[],
init(){
document.body.addEventListener('htmx:afterRequest', (event) => {
if(event.detail.successful && event.target === this.$el){ this.preview=null; }
});
document.body.addEventListener('tagsChanged', () => {
fetch('{% url "api_tags" %}').then(r=>r.json()).then(data=>{this.tags=data.tags||[]});
});
},
commands:[
{token:'/todo',label:'Aufgabe erstellen'},
{token:'/link',label:'Link speichern'},
{token:'/public',label:'öffentlich'},
{token:'/private',label:'privat'},
{token:'/code',label:'Markdown Codeblock einfügen'},
{token:'[[',label:'interne Aufgabe/Notiz suchen'}
],
get filtered(){
let q=this.query.toLowerCase();
let list=q.startsWith('#')
? this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q)).map(t=>({token:'#'+t,label:'Tag'}))
: q.startsWith('[[')
? this.linkSuggestions.map(i=>({token:`[[${i.id}]]`,label:`${i.kind}: ${i.label}`}))
: this.commands.filter(c=>!q||c.token.toLowerCase().startsWith(q));
return list.length?list:[{token:this.query,label:'neu'}];
},
onInput(e){this.update(e.target)},
handlePaste(e){
let item=[...(e.clipboardData?.items||[])].find(i=>i.kind==='file'&&i.type.startsWith('image/'));
if(!item) return;
let file=item.getAsFile();
let ext=(file.type.split('/')[1]||'png').replace('jpeg','jpg');
let named=new File([file], `paste-${new Date().toISOString().replace(/[:.]/g,'-')}.${ext}`, {type:file.type});
let dt=new DataTransfer();
dt.items.add(named);
this.$refs.fileInput.files=dt.files;
this.preview=URL.createObjectURL(named);
},
previewFile(e){
let file=e.target.files[0];
this.preview=file&&file.type.startsWith('image/')?URL.createObjectURL(file):null;
},
onKeydown(e){
this.currentEl=e.target;
if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){
e.preventDefault();
this.open=false;
e.target.form.requestSubmit();
return;
}
if(this.open&&e.key==='ArrowDown'){
e.preventDefault();
this.active=(this.active+1)%this.filtered.length;
return;
}
if(this.open&&e.key==='ArrowUp'){
e.preventDefault();
this.active=(this.active-1+this.filtered.length)%this.filtered.length;
return;
}
if(this.open&&(e.key==='Enter'||e.key==='Tab')&&!e.shiftKey){
e.preventDefault();
this.insert(this.filtered[this.active].token);
return;
}
if(e.key==='Escape'){
this.open=false;
return;
}
if(e.key===' '){
this.open=false;
return;
}
setTimeout(()=>this.update(e.target),0);
},
update(el){
this.currentEl=el;
this.text=el.value;
let before=el.value.slice(0,el.selectionStart);
let fragment=(before.match(/(^|\s)([^\s]*)$/)||['','',''])[2];
this.query=fragment;
this.active=0;
this.open=fragment.startsWith('/')||fragment.startsWith('#')||fragment.startsWith('[[');
if(fragment.startsWith('[[')){
let q=fragment.slice(2);
fetch(`{% url "api_item_suggestions" %}?q=${encodeURIComponent(q)}`).then(r=>r.json()).then(data=>{this.linkSuggestions=data.items||[]});
}
},
insert(token){
let el=this.currentEl || document.getElementById('id_content');
if(!el) return;
if(token==='/code') token='```\n\n```';
let pos=el.selectionStart;
let before=el.value.slice(0,pos);
let after=el.value.slice(pos);
let match=before.match(/(^|\s)([^\s]*)$/);
let start=match?pos-match[2].length:pos;
let prefix=start>0&&el.value[start-1]!==' '?' ':'';
let value=el.value.slice(0,start)+prefix+token+' '+after;
this.text=value;
el.value=value;
this.open=false;
this.$nextTick(()=>{
let p=start+prefix.length+token.length+1;
el.focus();
el.setSelectionRange(p,p);
});
}
}
}
</script>
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
{% extends 'core/base.html' %}
{% block content %}
<div class="row justify-content-center"><div class="col-md-5">
<div class="card item-card shadow-sm"><div class="card-body">
<h1 class="h4 mb-3">Anmelden</h1>
{% if form.errors %}<div class="alert alert-danger">Benutzername oder Passwort ist falsch.</div>{% endif %}
<form method="post">
{% csrf_token %}
<div class="mb-3"><label class="form-label">Benutzername</label><input class="form-control" name="username" autofocus></div>
<div class="mb-3"><label class="form-label">Passwort</label><input class="form-control" type="password" name="password"></div>
<button class="btn btn-dark w-100">Login</button>
</form>
</div></div>
</div></div>
{% endblock %}
+41
View File
@@ -0,0 +1,41 @@
{% extends 'core/base.html' %}
{% block content %}
<a class="btn btn-sm btn-outline-secondary mb-3" href="/">← zurück</a>
<div class="row g-4">
<section class="col-lg-6">
<div class="card item-card shadow-sm"><div class="card-body">
<h1 class="h4 mb-3">Benutzereinstellungen</h1>
<form method="post" class="vstack gap-3">
{% csrf_token %}<input type="hidden" name="action" value="profile">
<div><label class="form-label">Vorname</label>{{ profile_form.first_name }}</div>
<div><label class="form-label">Name</label>{{ profile_form.last_name }}</div>
<div><label class="form-label">E-Mail</label>{{ profile_form.email }}</div>
<button class="btn btn-dark">Speichern</button>
</form>
</div></div>
</section>
<section class="col-lg-6">
<div class="card item-card shadow-sm mb-3"><div class="card-body">
<h2 class="h5 mb-3">API Key erzeugen</h2>
<form method="post" class="vstack gap-3">
{% csrf_token %}<input type="hidden" name="action" value="apikey">
<div><label class="form-label">Name</label>{{ key_form.name }}</div>
<div><label class="form-label">Auf Tags beschränken</label><div class="small text-muted mb-2">Keine Auswahl = Zugriff auf alle eigenen Einträge.</div>{{ key_form.tags }}</div>
<button class="btn btn-dark">API Key generieren</button>
</form>
</div></div>
<div class="card item-card shadow-sm"><div class="card-body">
<h2 class="h5 mb-3">API Keys</h2>
<div class="vstack gap-2">
{% for key in api_keys %}
<div class="border rounded p-2">
<div class="d-flex justify-content-between gap-2"><strong>{{ key.name }}</strong><form method="post" action="{% url 'delete_api_key' key.id %}">{% csrf_token %}<button class="btn btn-sm btn-outline-danger">Löschen</button></form></div>
<code class="small user-select-all">{{ key.token }}</code>
<div class="small text-muted">Tags: {% for tag in key.tags.all %}#{{ tag.name }} {% empty %}alle{% endfor %}</div>
</div>
{% empty %}<span class="text-muted">Noch keine API Keys.</span>{% endfor %}
</div>
</div></div>
</section>
</div>
{% endblock %}