Add journal, editor, and overview improvements
This commit is contained in:
+2
-1
@@ -1,5 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from .models import ApiKey, Attachment, Item, Tag
|
||||
from .models import ApiKey, Attachment, Item, Tag, UserPreference
|
||||
|
||||
|
||||
class AttachmentInline(admin.TabularInline):
|
||||
@@ -22,3 +22,4 @@ class ApiKeyAdmin(admin.ModelAdmin):
|
||||
|
||||
|
||||
admin.site.register(Tag)
|
||||
admin.site.register(UserPreference)
|
||||
|
||||
+30
-3
@@ -1,10 +1,22 @@
|
||||
from django import forms
|
||||
from django.contrib.auth.models import User
|
||||
from .models import ApiKey, Attachment, Item
|
||||
from .models import ApiKey, Attachment, Item, UserPreference
|
||||
|
||||
|
||||
class MultipleFileInput(forms.ClearableFileInput):
|
||||
allow_multiple_selected = True
|
||||
|
||||
|
||||
class MultipleFileField(forms.FileField):
|
||||
def clean(self, data, initial=None):
|
||||
if not data:
|
||||
return []
|
||||
files = data if isinstance(data, (list, tuple)) else [data]
|
||||
return [super(MultipleFileField, self).clean(file, initial) for file in files]
|
||||
|
||||
|
||||
class QuickItemForm(forms.ModelForm):
|
||||
files = forms.FileField(required=False, widget=forms.ClearableFileInput(attrs={'multiple': False}))
|
||||
files = MultipleFileField(required=False, widget=MultipleFileInput(attrs={'multiple': True}))
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
@@ -21,7 +33,7 @@ class QuickItemForm(forms.ModelForm):
|
||||
|
||||
|
||||
class EditItemForm(forms.ModelForm):
|
||||
files = forms.FileField(required=False, widget=forms.ClearableFileInput())
|
||||
files = MultipleFileField(required=False, widget=MultipleFileInput(attrs={'multiple': True}))
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
@@ -44,6 +56,21 @@ class UserSettingsForm(forms.ModelForm):
|
||||
widgets = {field: forms.TextInput(attrs={'class': 'form-control'}) for field in ['first_name', 'last_name', 'email']}
|
||||
|
||||
|
||||
class UserPreferenceForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = UserPreference
|
||||
fields = ['show_notes', 'show_open_todos', 'show_done_todos', 'show_links', 'show_journal', 'overview_lines']
|
||||
labels = {
|
||||
'show_notes': 'Notizen anzeigen',
|
||||
'show_open_todos': 'Offene Aufgaben anzeigen',
|
||||
'show_done_todos': 'Erledigte Aufgaben anzeigen',
|
||||
'show_links': 'Links anzeigen',
|
||||
'show_journal': 'Journal anzeigen',
|
||||
'overview_lines': 'Zeilen in der Übersicht',
|
||||
}
|
||||
widgets = {'overview_lines': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 50})}
|
||||
|
||||
|
||||
class ApiKeyForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ApiKey
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('core', '0004_item_comment')]
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='item',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('todo', 'Aufgabe'), ('note', 'Notiz'), ('link', 'Link'), ('journal', 'Journal')], default='note', max_length=10),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('core', '0005_journal_kind'), migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='UserPreference',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('show_notes', models.BooleanField(default=True)),
|
||||
('show_open_todos', models.BooleanField(default=True)),
|
||||
('show_done_todos', models.BooleanField(default=False)),
|
||||
('show_links', models.BooleanField(default=True)),
|
||||
('show_journal', models.BooleanField(default=False)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='preferences', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('core', '0006_userpreference')]
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userpreference',
|
||||
name='overview_lines',
|
||||
field=models.PositiveSmallIntegerField(default=4),
|
||||
),
|
||||
]
|
||||
@@ -23,6 +23,7 @@ class Item(models.Model):
|
||||
TODO = 'todo', 'Aufgabe'
|
||||
NOTE = 'note', 'Notiz'
|
||||
LINK = 'link', 'Link'
|
||||
JOURNAL = 'journal', 'Journal'
|
||||
|
||||
class Visibility(models.TextChoices):
|
||||
PRIVATE = 'private', 'Privat'
|
||||
@@ -58,6 +59,19 @@ class Item(models.Model):
|
||||
self.linked_items.set(Item.objects.filter(id__in=ids).exclude(id=self.id))
|
||||
|
||||
|
||||
class UserPreference(models.Model):
|
||||
user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='preferences')
|
||||
show_notes = models.BooleanField(default=True)
|
||||
show_open_todos = models.BooleanField(default=True)
|
||||
show_done_todos = models.BooleanField(default=False)
|
||||
show_links = models.BooleanField(default=True)
|
||||
show_journal = models.BooleanField(default=False)
|
||||
overview_lines = models.PositiveSmallIntegerField(default=4)
|
||||
|
||||
def __str__(self):
|
||||
return f'Einstellungen {self.user}'
|
||||
|
||||
|
||||
class ApiKey(models.Model):
|
||||
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='api_keys')
|
||||
name = models.CharField(max_length=120, default='API Key')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<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">
|
||||
<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 %}], {{ item.id }})" 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>
|
||||
@@ -13,12 +13,13 @@
|
||||
<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 class="col-md-4"><input class="form-control" type="file" name="files" multiple 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>
|
||||
<a class="btn btn-outline-primary btn-sm" href="{% url 'item_editor' item.id %}?next={{ request.get_full_path|urlencode }}">Im Editor öffnen</a>
|
||||
<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>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
{% load markdown_extras %}
|
||||
<article id="item-{{ item.id }}" class="card item-card shadow-sm {% if item.is_done %}done{% endif %}">
|
||||
<article id="item-{{ item.id }}" class="card item-card shadow-sm {% if item.is_done %}done{% endif %}" ondblclick="if(!event.target.closest('a,button,input,textarea,select,label')) window.location.href='{{ item.get_absolute_url }}?next={{ request.get_full_path|urlencode }}'">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start gap-3">
|
||||
<div class="flex-grow-1">
|
||||
<div class="flex-grow-1 min-w-0" style="min-width:0">
|
||||
<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 {% if item.kind == 'todo' %}text-bg-primary{% elif item.kind == 'link' %}text-bg-success{% elif item.kind == 'journal' %}text-bg-info{% 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>
|
||||
<a class="text-muted small" href="{{ item.get_absolute_url }}?next={{ request.get_full_path|urlencode }}">#{{ 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>
|
||||
<div x-data="{expanded:false,showMore:false,check(){this.$nextTick(()=>{let el=this.$refs.content;this.showMore=el && el.scrollHeight > el.clientHeight + 2})}}" x-init="check()" class="position-relative">
|
||||
<div x-ref="content" class="content overview-content" :class="expanded ? 'expanded' : ''" style="--overview-lines: {{ overview_lines|default:999 }}">{{ item|item_markdown }}</div>
|
||||
{% if overview_lines %}<button x-show="showMore" type="button" class="btn btn-link btn-sm p-0 mt-1" @click="expanded=!expanded" x-text="expanded ? 'weniger anzeigen' : 'mehr anzeigen'"></button>{% endif %}
|
||||
</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 %}
|
||||
@@ -17,13 +20,17 @@
|
||||
{% 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 %}
|
||||
{% for a in item.attachments.all %}{% if a.file.name|is_image and not a|is_embedded:item %}<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>
|
||||
{% if request.resolver_match.url_name == 'item_detail' %}
|
||||
<a class="btn btn-outline-secondary" href="{% url 'item_editor' item.id %}?next={{ request.GET.next|default:'/'|urlencode }}">✎</a>
|
||||
{% else %}
|
||||
<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>
|
||||
{% endif %}
|
||||
<button class="btn btn-outline-danger" hx-post="{% url 'delete_item' item.id %}" hx-target="#item-{{ item.id }}" hx-swap="delete">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,29 +9,32 @@
|
||||
<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}
|
||||
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}.content img{max-width:100%;max-height:420px;border-radius:.5rem;border:1px solid #ddd;padding:.25rem;background:white}.overview-content{max-height:calc(var(--overview-lines) * 1.55em);overflow:hidden}.overview-content.expanded{max-height:none}.content{min-width:0;max-width:100%;overflow-wrap:anywhere}.content pre{position:relative;display:block;width:100%;max-width:100%;box-sizing:border-box;background:#1f2937;color:#f9fafb;border-radius:.6rem;padding:1rem;overflow-x:auto;overflow-y:auto;white-space:pre}.content pre code{display:block;color:inherit;white-space:pre;min-width:0}.copy-code{position:absolute;top:.4rem;right:.4rem;line-height:1}
|
||||
</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>
|
||||
<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 'journal' %}">Journal</a><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){
|
||||
window.quickComposer = window.quickComposer || function(tags, itemId=null){
|
||||
return {
|
||||
open:false,text:'',active:0,query:'',currentEl:null,preview:null,linkSuggestions:[],tags:tags||[],
|
||||
open:false,text:'',active:0,query:'',currentEl:null,preview:null,pendingFiles:[],linkSuggestions:[],attachmentSuggestions:[],itemId:itemId,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'}]},
|
||||
commands:[{token:'/todo',label:'Aufgabe erstellen'},{token:'/link',label:'Link speichern'},{token:'/journal',label:'Journal-Eintrag'},{token:'/public',label:'öffentlich'},{token:'/private',label:'privat'},{token:'/code',label:'Markdown Codeblock einfügen'},{token:'[[',label:'interne Aufgabe/Notiz suchen'},{token:'![[',label:'Bild aus Anhängen einfügen'}],
|
||||
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.itemId?this.attachmentSuggestions.map(a=>({token:`![[file:${a.id}]]`,label:`Bild: ${a.name}`})):this.pendingFiles.map(f=>({token:`![[paste:${f.name}]]`,label:`Bild: ${f.name}`}))):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},
|
||||
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();[...(this.$refs.fileInput.files||[])].forEach(f=>dt.items.add(f));dt.items.add(named);this.$refs.fileInput.files=dt.files;this.pendingFiles=[...dt.files].filter(f=>f.type.startsWith('image/'));this.preview=URL.createObjectURL(named)},
|
||||
insertImageToken(){if(this.pendingFiles.length)this.insert(`![[paste:${this.pendingFiles[this.pendingFiles.length-1].name}]]`)},
|
||||
previewFile(e){this.pendingFiles=[...e.target.files].filter(f=>f.type.startsWith('image/'));let file=this.pendingFiles[0];this.preview=file?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)})}
|
||||
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('[[')||fragment.startsWith('![[');if(fragment.startsWith('![[')&&this.itemId){fetch(`/api/attachment-suggestions/?item=${this.itemId}`).then(r=>r.json()).then(d=>{this.attachmentSuggestions=d.attachments||[]})}else 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;let isCode=token==='/code';if(isCode)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 needsPrefix=start>0&&el.value[start-1]!=='\n'&&el.value[start-1]!==' ';let prefix=isCode?(start>0&&el.value[start-1]!=='\n'?'\n':''):(needsPrefix?' ':'');let suffix=isCode?'': ' ';let value=el.value.slice(0,start)+prefix+token+suffix+after;this.text=value;el.value=value;this.open=false;this.$nextTick(()=>{let p=isCode?start+prefix.length+4:start+prefix.length+token.length+suffix.length;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()}}}
|
||||
function enhanceCodeBlocks(root=document){root.querySelectorAll('.content pre:not([data-copy])').forEach(pre=>{pre.dataset.copy='1';let btn=document.createElement('button');btn.type='button';btn.className='copy-code btn btn-sm btn-light';btn.title='Kopieren';btn.innerHTML='⧉';btn.addEventListener('click',()=>navigator.clipboard.writeText(pre.querySelector('code')?.innerText || pre.innerText).then(()=>{btn.innerHTML='✓';setTimeout(()=>btn.innerHTML='⧉',1200)}));pre.appendChild(btn)})}
|
||||
document.addEventListener('DOMContentLoaded',()=>enhanceCodeBlocks());document.body.addEventListener('htmx:afterSwap',e=>enhanceCodeBlocks(e.target));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% block content %}
|
||||
<a class="btn btn-sm btn-outline-secondary mb-3" href="/">← zurück</a>
|
||||
<a class="btn btn-sm btn-outline-secondary mb-3" href="{{ request.GET.next|default:'/' }}">← 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 %}
|
||||
<script>
|
||||
document.addEventListener('keydown', function(event){
|
||||
if(event.key === 'Escape') window.location.href = '{{ request.GET.next|default:"/"|escapejs }}';
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
<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_kind == 'todo' %}btn-primary{% else %}btn-outline-primary{% endif %}" href="{% if active_kind == 'todo' %}{% query_replace kind=None %}{% else %}{% query_replace kind='todo' %}{% endif %}">Todos{% if active_kind == 'todo' %} ×{% endif %}</a>
|
||||
<a class="btn btn-sm {% if active_kind == 'note' %}btn-secondary{% else %}btn-outline-secondary{% endif %}" href="{% if active_kind == 'note' %}{% query_replace kind=None %}{% else %}{% query_replace kind='note' %}{% endif %}">Notizen{% if active_kind == 'note' %} ×{% endif %}</a>
|
||||
<a class="btn btn-sm {% if active_kind == 'link' %}btn-success{% else %}btn-outline-success{% endif %}" href="{% if active_kind == 'link' %}{% query_replace kind=None %}{% else %}{% query_replace kind='link' %}{% endif %}">Links{% if active_kind == 'link' %} ×{% endif %}</a>
|
||||
<a class="btn btn-sm {% if active_kind == 'journal' %}btn-info{% else %}btn-outline-info{% endif %}" href="{% if active_kind == 'journal' %}{% query_replace kind=None day=None %}{% else %}{% query_replace kind='journal' %}{% endif %}">Journal{% if active_kind == 'journal' %} ×{% endif %}</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>
|
||||
@@ -17,11 +17,22 @@
|
||||
{% 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="input-group input-group-sm">
|
||||
<input x-ref="search" class="form-control" 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)">
|
||||
{% if q %}<a class="btn btn-outline-secondary" href="{% query_replace q=None %}" title="Suche löschen">×</a>{% endif %}
|
||||
</div>
|
||||
<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>
|
||||
{% if active_kind == 'journal' and journal_days %}
|
||||
<div class="fw-semibold mb-2">Journal-Kalender</div>
|
||||
<div class="d-flex flex-wrap gap-1 mb-3">
|
||||
{% for d in journal_days %}
|
||||
{% if d.has_entries %}<a class="btn btn-sm {% if active_day == d.date %}btn-info{% else %}btn-outline-info{% endif %}" href="{% query_replace day=d.date %}">{{ d.day }}</a>{% else %}<span class="btn btn-sm btn-light disabled">{{ d.day }}</span>{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<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' %}
|
||||
@@ -36,7 +47,8 @@
|
||||
<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)">
|
||||
<input class="form-control" type="file" name="files" multiple x-ref="fileInput" @change="previewFile($event)">
|
||||
<a class="btn btn-outline-secondary" href="{% url 'new_item' %}">Editor</a>
|
||||
<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>
|
||||
@@ -47,7 +59,7 @@
|
||||
</section>
|
||||
</div>
|
||||
<script>
|
||||
function quickComposer(tags){
|
||||
function quickComposer(tags, itemId=null){
|
||||
return {
|
||||
open:false,
|
||||
text:'',
|
||||
@@ -56,6 +68,9 @@ function quickComposer(tags){
|
||||
currentEl:null,
|
||||
preview:null,
|
||||
linkSuggestions:[],
|
||||
attachmentSuggestions:[],
|
||||
pendingFiles:[],
|
||||
itemId:itemId,
|
||||
tags:tags||[],
|
||||
init(){
|
||||
document.body.addEventListener('htmx:afterRequest', (event) => {
|
||||
@@ -68,18 +83,22 @@ function quickComposer(tags){
|
||||
commands:[
|
||||
{token:'/todo',label:'Aufgabe erstellen'},
|
||||
{token:'/link',label:'Link speichern'},
|
||||
{token:'/journal',label:'Journal-Eintrag'},
|
||||
{token:'/public',label:'öffentlich'},
|
||||
{token:'/private',label:'privat'},
|
||||
{token:'/code',label:'Markdown Codeblock einfügen'},
|
||||
{token:'[[',label:'interne Aufgabe/Notiz suchen'}
|
||||
{token:'[[',label:'interne Aufgabe/Notiz suchen'},
|
||||
{token:'![[',label:'Bild aus Anhängen einfügen'}
|
||||
],
|
||||
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));
|
||||
: q.startsWith('![[')
|
||||
? (this.itemId?this.attachmentSuggestions.map(a=>({token:`![[file:${a.id}]]`,label:`Bild: ${a.name}`})):this.pendingFiles.map(f=>({token:`![[paste:${f.name}]]`,label:`Bild: ${f.name}`})))
|
||||
: 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)},
|
||||
@@ -90,13 +109,16 @@ function quickComposer(tags){
|
||||
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();
|
||||
[...(this.$refs.fileInput.files||[])].forEach(f=>dt.items.add(f));
|
||||
dt.items.add(named);
|
||||
this.$refs.fileInput.files=dt.files;
|
||||
this.pendingFiles=[...dt.files].filter(f=>f.type.startsWith('image/'));
|
||||
this.preview=URL.createObjectURL(named);
|
||||
},
|
||||
previewFile(e){
|
||||
let file=e.target.files[0];
|
||||
this.preview=file&&file.type.startsWith('image/')?URL.createObjectURL(file):null;
|
||||
this.pendingFiles=[...e.target.files].filter(f=>f.type.startsWith('image/'));
|
||||
let file=this.pendingFiles[0];
|
||||
this.preview=file?URL.createObjectURL(file):null;
|
||||
},
|
||||
onKeydown(e){
|
||||
this.currentEl=e.target;
|
||||
@@ -138,8 +160,10 @@ function quickComposer(tags){
|
||||
let fragment=(before.match(/(^|\s)([^\s]*)$/)||['','',''])[2];
|
||||
this.query=fragment;
|
||||
this.active=0;
|
||||
this.open=fragment.startsWith('/')||fragment.startsWith('#')||fragment.startsWith('[[');
|
||||
if(fragment.startsWith('[[')){
|
||||
this.open=fragment.startsWith('/')||fragment.startsWith('#')||fragment.startsWith('[[')||fragment.startsWith('![[');
|
||||
if(fragment.startsWith('![[')&&this.itemId){
|
||||
fetch(`/api/attachment-suggestions/?item=${this.itemId}`).then(r=>r.json()).then(data=>{this.attachmentSuggestions=data.attachments||[]});
|
||||
}else 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||[]});
|
||||
}
|
||||
@@ -147,19 +171,22 @@ function quickComposer(tags){
|
||||
insert(token){
|
||||
let el=this.currentEl || document.getElementById('id_content');
|
||||
if(!el) return;
|
||||
if(token==='/code') token='```\n\n```';
|
||||
let isCode=token==='/code';
|
||||
if(isCode) 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;
|
||||
let needsPrefix=start>0&&el.value[start-1]!=='\n'&&el.value[start-1]!==' ';
|
||||
let prefix=isCode?(start>0&&el.value[start-1]!=='\n'?'\n':''):(needsPrefix?' ':'');
|
||||
let suffix=isCode?'':' ';
|
||||
let value=el.value.slice(0,start)+prefix+token+suffix+after;
|
||||
this.text=value;
|
||||
el.value=value;
|
||||
this.open=false;
|
||||
this.$nextTick(()=>{
|
||||
let p=start+prefix.length+token.length+1;
|
||||
let p=isCode?start+prefix.length+4:start+prefix.length+token.length+suffix.length;
|
||||
el.focus();
|
||||
el.setSelectionRange(p,p);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% block content %}
|
||||
<a class="btn btn-sm btn-outline-secondary mb-3" href="{{ next_url }}">← zurück</a>
|
||||
<form class="card item-card shadow-sm position-relative" method="post" enctype="multipart/form-data" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}], {{ item.id }})" x-init="text=$el.querySelector('textarea[name=content]').value">
|
||||
{% csrf_token %}
|
||||
<div class="card-body vstack gap-3">
|
||||
<h1 class="h4">Eintrag #{{ item.id }} im Editor bearbeiten</h1>
|
||||
<div class="small text-muted">Typ: {{ item.get_kind_display }}</div>
|
||||
<textarea class="form-control" name="content" rows="18" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.content }}</textarea>
|
||||
<textarea class="form-control" name="comment" rows="5" placeholder="Kommentar" @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' }}"></div>{% endif %}
|
||||
<div class="col-md-4"><input class="form-control" type="file" name="files" multiple 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">Bildvorschau:</span><br><img :src="preview" class="img-thumbnail mt-1" style="max-height:180px"></div></template>
|
||||
<div class="d-flex gap-2"><button class="btn btn-dark">Speichern</button><a class="btn btn-outline-secondary" href="{{ next_url }}">Abbrechen</a></div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% load markdown_extras %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/">← zurück</a>
|
||||
<h1 class="h4 m-0">Journal {{ month }}/{{ year }}</h1>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<a class="btn btn-outline-dark" href="?year={{ prev_year }}&month={{ prev_month }}">←</a>
|
||||
<a class="btn btn-outline-dark" href="?year={{ next_year }}&month={{ next_month }}">→</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card item-card shadow-sm"><div class="card-body table-responsive">
|
||||
<table class="table table-bordered align-top mb-0">
|
||||
<thead><tr><th>Mo</th><th>Di</th><th>Mi</th><th>Do</th><th>Fr</th><th>Sa</th><th>So</th></tr></thead>
|
||||
<tbody>
|
||||
{% for week in weeks %}<tr>{% for day, entries in week %}<td style="height:120px;width:14%" {% if not day %}class="bg-light"{% endif %}>
|
||||
{% if day %}<div class="fw-semibold small mb-1">{{ day }}</div>{% endif %}
|
||||
{% for item in entries %}<a class="d-block small text-decoration-none mb-1" href="{{ item.get_absolute_url }}">{{ item.content|truncatechars:55 }}</a>{% endfor %}
|
||||
</td>{% endfor %}</tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div></div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends 'core/base.html' %}
|
||||
{% block content %}
|
||||
<a class="btn btn-sm btn-outline-secondary mb-3" href="/">← zurück</a>
|
||||
<form class="card item-card shadow-sm position-relative" method="post" enctype="multipart/form-data" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])">
|
||||
{% csrf_token %}
|
||||
<div class="card-body vstack gap-3">
|
||||
<h1 class="h4">Erweiterter Editor</h1>
|
||||
<textarea class="form-control" name="content" rows="16" placeholder="Längeres Dokument … /todo /link /journal /public #tag [[ ![[" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)"></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>
|
||||
<label class="form-label">Bilder/Dateien</label>
|
||||
<input class="form-control" type="file" name="files" multiple x-ref="fileInput" @change="previewFile($event)">
|
||||
<div class="form-text">Mit <code>![[</code> können hochgeladene oder gepastete Bilder im Text eingefügt werden.</div>
|
||||
</div>
|
||||
<template x-if="preview"><div class="small"><span class="text-muted">Bildvorschau:</span><br><img :src="preview" class="img-thumbnail mt-1" style="max-height:180px"></div></template>
|
||||
<button class="btn btn-dark align-self-start">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -15,6 +15,20 @@
|
||||
</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">Standardansicht</h2>
|
||||
<form method="post" class="vstack gap-2">
|
||||
{% csrf_token %}<input type="hidden" name="action" value="preferences">
|
||||
{% for field in preference_form %}
|
||||
{% if field.field.widget.input_type == 'checkbox' %}
|
||||
<label class="form-check"><input class="form-check-input" type="checkbox" name="{{ field.name }}" {% if field.value %}checked{% endif %}> <span class="form-check-label">{{ field.label }}</span></label>
|
||||
{% else %}
|
||||
<div><label class="form-label">{{ field.label }}</label>{{ field }}</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<button class="btn btn-dark mt-2">Standardansicht speichern</button>
|
||||
</form>
|
||||
</div></div>
|
||||
<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">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
import bleach
|
||||
import markdown as md
|
||||
from django import template
|
||||
@@ -7,9 +8,10 @@ register = template.Library()
|
||||
|
||||
ALLOWED_TAGS = set(bleach.sanitizer.ALLOWED_TAGS) | {
|
||||
'p', 'pre', 'code', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'ul', 'ol', 'li',
|
||||
'strong', 'em', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'br'
|
||||
'strong', 'em', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'br', 'img'
|
||||
}
|
||||
ALLOWED_ATTRS = {'a': ['href', 'title', 'rel'], 'code': ['class']}
|
||||
ALLOWED_ATTRS = {'a': ['href', 'title', 'rel'], 'code': ['class'], 'img': ['src', 'alt', 'title', 'class']}
|
||||
FILE_RE = re.compile(r'!\[\[file:(\d+)\]\]')
|
||||
|
||||
|
||||
@register.filter
|
||||
@@ -18,6 +20,26 @@ def markdown(text):
|
||||
return mark_safe(bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS))
|
||||
|
||||
|
||||
@register.filter
|
||||
def item_markdown(item):
|
||||
text = item.content or ''
|
||||
attachments = {str(a.id): a for a in item.attachments.all()}
|
||||
|
||||
def replace(match):
|
||||
attachment = attachments.get(match.group(1))
|
||||
if not attachment:
|
||||
return match.group(0)
|
||||
return f''
|
||||
|
||||
return markdown(FILE_RE.sub(replace, text))
|
||||
|
||||
|
||||
@register.filter
|
||||
def is_image(file_name):
|
||||
return str(file_name).lower().split('?')[0].endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'))
|
||||
|
||||
|
||||
@register.filter
|
||||
def is_embedded(attachment, item):
|
||||
token = f'![[file:{attachment.id}]]'
|
||||
return token in (item.content or '') or token in (item.comment or '')
|
||||
|
||||
@@ -6,16 +6,20 @@ urlpatterns = [
|
||||
path('', views.index, name='index'),
|
||||
path('login/', auth_views.LoginView.as_view(template_name='core/login.html'), name='login'),
|
||||
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
|
||||
path('new/', views.new_item, name='new_item'),
|
||||
path('journal/', views.journal, name='journal'),
|
||||
path('settings/', views.settings_view, name='settings'),
|
||||
path('settings/api-keys/<int:pk>/delete/', views.delete_api_key, name='delete_api_key'),
|
||||
path('items/<int:pk>/', views.item_detail, name='item_detail'),
|
||||
path('items/<int:pk>/card/', views.item_card, name='item_card'),
|
||||
path('items/<int:pk>/edit/', views.edit_item, name='edit_item'),
|
||||
path('items/<int:pk>/editor/', views.item_editor, name='item_editor'),
|
||||
path('items/<int:pk>/toggle/', views.toggle_done, name='toggle_done'),
|
||||
path('items/<int:pk>/delete/', views.delete_item, name='delete_item'),
|
||||
path('tags/', views.tag_list, name='tag_list'),
|
||||
path('api/tags/', views.api_tags, name='api_tags'),
|
||||
path('api/item-suggestions/', views.api_item_suggestions, name='api_item_suggestions'),
|
||||
path('api/attachment-suggestions/', views.api_attachment_suggestions, name='api_attachment_suggestions'),
|
||||
path('api/items/', views.api_items, name='api_items'),
|
||||
path('api/items/<int:pk>/', views.api_item_detail, name='api_item_detail'),
|
||||
]
|
||||
|
||||
+122
-11
@@ -1,6 +1,8 @@
|
||||
import json
|
||||
import re
|
||||
from functools import wraps
|
||||
from calendar import Calendar
|
||||
from datetime import date
|
||||
from html import unescape
|
||||
from urllib.request import Request, urlopen
|
||||
from django.contrib import messages
|
||||
@@ -10,10 +12,10 @@ from django.http import HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods, require_POST
|
||||
from .forms import ApiKeyForm, EditItemForm, QuickItemForm, UserSettingsForm
|
||||
from .models import ApiKey, Attachment, Item, Tag
|
||||
from .forms import ApiKeyForm, EditItemForm, QuickItemForm, UserPreferenceForm, UserSettingsForm
|
||||
from .models import ApiKey, Attachment, Item, Tag, UserPreference
|
||||
|
||||
COMMAND_RE = re.compile(r'/(todo|note|link|public|private)\b', re.I)
|
||||
COMMAND_RE = re.compile(r'/(todo|note|link|journal|public|private)\b', re.I)
|
||||
URL_RE = re.compile(r'https?://\S+')
|
||||
ONLY_URL_RE = re.compile(r'^https?://\S+$')
|
||||
TITLE_RE = re.compile(r'<title[^>]*>(.*?)</title>', re.I | re.S)
|
||||
@@ -37,7 +39,7 @@ def parse_quick_content(raw):
|
||||
commands = {c.lower() for c in COMMAND_RE.findall(content)}
|
||||
content = COMMAND_RE.sub('', content).strip()
|
||||
only_url = bool(ONLY_URL_RE.match(content))
|
||||
kind = Item.Kind.TODO if 'todo' in commands else Item.Kind.LINK if 'link' in commands or only_url else Item.Kind.NOTE
|
||||
kind = Item.Kind.TODO if 'todo' in commands else Item.Kind.JOURNAL if 'journal' in commands else Item.Kind.LINK if 'link' in commands or only_url else Item.Kind.NOTE
|
||||
visibility = Item.Visibility.PUBLIC if 'public' in commands else Item.Visibility.PRIVATE
|
||||
url_match = URL_RE.search(content)
|
||||
url = url_match.group(0) if kind == Item.Kind.LINK and url_match else ''
|
||||
@@ -46,6 +48,23 @@ def parse_quick_content(raw):
|
||||
return kind, visibility, content, url
|
||||
|
||||
|
||||
def attach_files_and_replace_tokens(item, files):
|
||||
replacements = {}
|
||||
for file in files:
|
||||
attachment = Attachment.objects.create(item=item, file=file)
|
||||
replacements[f'![[paste:{file.name}]]'] = f'![[file:{attachment.id}]]'
|
||||
if replacements:
|
||||
content = item.content
|
||||
comment = item.comment
|
||||
for old, new in replacements.items():
|
||||
content = content.replace(old, new)
|
||||
comment = comment.replace(old, new)
|
||||
if content != item.content or comment != item.comment:
|
||||
item.content = content
|
||||
item.comment = comment
|
||||
item.save(update_fields=['content', 'comment', 'updated_at'])
|
||||
|
||||
|
||||
def visible_items(user, api_key=None):
|
||||
qs = Item.objects.select_related('owner').prefetch_related('tags', 'attachments', 'linked_items')
|
||||
if api_key:
|
||||
@@ -86,9 +105,8 @@ def index(request):
|
||||
if active_tag and f'#{active_tag.lower()}' not in content.lower():
|
||||
content = f'{content} #{active_tag}'
|
||||
item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url)
|
||||
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
|
||||
item.sync_metadata()
|
||||
if request.FILES.get('files'):
|
||||
Attachment.objects.create(item=item, file=request.FILES['files'])
|
||||
if request.headers.get('HX-Request'):
|
||||
response = render(request, 'core/_create_response.html', {'item': item, 'tags': Tag.objects.all(), 'active_tag': request.GET.get('tag')})
|
||||
response['HX-Trigger'] = 'tagsChanged'
|
||||
@@ -97,6 +115,7 @@ def index(request):
|
||||
tag = request.GET.get('tag')
|
||||
kind = request.GET.get('kind')
|
||||
status = request.GET.get('status')
|
||||
day = request.GET.get('day')
|
||||
q = request.GET.get('q', '').strip()
|
||||
if q.startswith('#') and len(q) > 1:
|
||||
query = request.GET.copy()
|
||||
@@ -104,20 +123,83 @@ def index(request):
|
||||
query.pop('q', None)
|
||||
return redirect(f'{request.path}?{query.urlencode()}')
|
||||
items = visible_items(request.user)
|
||||
applied_preferences = False
|
||||
if tag:
|
||||
items = items.filter(tags__name=tag)
|
||||
if kind in dict(Item.Kind.choices):
|
||||
items = items.filter(kind=kind)
|
||||
elif not any([tag, status, q, day]):
|
||||
applied_preferences = True
|
||||
prefs, _ = UserPreference.objects.get_or_create(user=request.user)
|
||||
type_filter = Q()
|
||||
if prefs.show_notes:
|
||||
type_filter |= Q(kind=Item.Kind.NOTE)
|
||||
if prefs.show_links:
|
||||
type_filter |= Q(kind=Item.Kind.LINK)
|
||||
if prefs.show_journal:
|
||||
type_filter |= Q(kind=Item.Kind.JOURNAL)
|
||||
if prefs.show_open_todos:
|
||||
type_filter |= Q(kind=Item.Kind.TODO, is_done=False)
|
||||
if prefs.show_done_todos:
|
||||
type_filter |= Q(kind=Item.Kind.TODO, is_done=True)
|
||||
items = items.filter(type_filter) if type_filter else items.none()
|
||||
if day:
|
||||
items = items.filter(created_at__date=day)
|
||||
if status == 'open':
|
||||
items = items.filter(kind=Item.Kind.TODO, is_done=False)
|
||||
elif status == 'archive':
|
||||
items = items.filter(kind=Item.Kind.TODO, is_done=True)
|
||||
else:
|
||||
elif not applied_preferences:
|
||||
items = items.exclude(kind=Item.Kind.TODO, is_done=True)
|
||||
if q:
|
||||
items = items.filter(Q(content__icontains=q) | Q(url__icontains=q) | Q(tags__name__icontains=q)).distinct()
|
||||
prefs, _ = UserPreference.objects.get_or_create(user=request.user)
|
||||
journal_days = []
|
||||
if kind == Item.Kind.JOURNAL:
|
||||
today = date.today()
|
||||
year = int(request.GET.get('year', today.year))
|
||||
month = int(request.GET.get('month', today.month))
|
||||
journal_qs = visible_items(request.user).filter(kind=Item.Kind.JOURNAL, created_at__year=year, created_at__month=month)
|
||||
days_with_entries = set(journal_qs.dates('created_at', 'day'))
|
||||
journal_days = [
|
||||
{'day': d, 'date': date(year, month, d).isoformat(), 'has_entries': date(year, month, d) in days_with_entries}
|
||||
for week in Calendar(firstweekday=0).monthdayscalendar(year, month) for d in week if d
|
||||
]
|
||||
return render(request, 'core/index.html', {
|
||||
'form': QuickItemForm(), 'items': items, 'tags': Tag.objects.all(), 'active_tag': tag, 'active_kind': kind, 'active_status': status, 'q': q,
|
||||
'form': QuickItemForm(), 'items': items, 'tags': Tag.objects.all(), 'active_tag': tag, 'active_kind': kind, 'active_status': status, 'active_day': day, 'q': q, 'journal_days': journal_days, 'overview_lines': prefs.overview_lines,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def new_item(request):
|
||||
if request.method == 'POST':
|
||||
form = QuickItemForm(request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
kind, visibility, content, url = parse_quick_content(form.cleaned_data['content'])
|
||||
item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url)
|
||||
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
|
||||
item.sync_metadata()
|
||||
return redirect(item.get_absolute_url())
|
||||
return render(request, 'core/new_item.html', {'form': QuickItemForm(), 'tags': Tag.objects.all()})
|
||||
|
||||
|
||||
@login_required
|
||||
def journal(request):
|
||||
today = date.today()
|
||||
year = int(request.GET.get('year', today.year))
|
||||
month = int(request.GET.get('month', today.month))
|
||||
entries = visible_items(request.user).filter(kind=Item.Kind.JOURNAL, created_at__year=year, created_at__month=month)
|
||||
by_day = {}
|
||||
for item in entries:
|
||||
by_day.setdefault(item.created_at.day, []).append(item)
|
||||
weeks = []
|
||||
for week in Calendar(firstweekday=0).monthdayscalendar(year, month):
|
||||
weeks.append([(day, by_day.get(day, [])) for day in week])
|
||||
prev_month, prev_year = (12, year - 1) if month == 1 else (month - 1, year)
|
||||
next_month, next_year = (1, year + 1) if month == 12 else (month + 1, year)
|
||||
return render(request, 'core/journal.html', {
|
||||
'weeks': weeks, 'year': year, 'month': month,
|
||||
'prev_year': prev_year, 'prev_month': prev_month, 'next_year': next_year, 'next_month': next_month,
|
||||
})
|
||||
|
||||
|
||||
@@ -130,6 +212,13 @@ def settings_view(request):
|
||||
form.save()
|
||||
messages.success(request, 'Einstellungen gespeichert.')
|
||||
return redirect('settings')
|
||||
elif request.POST.get('action') == 'preferences':
|
||||
prefs, _ = UserPreference.objects.get_or_create(user=request.user)
|
||||
pref_form = UserPreferenceForm(request.POST, instance=prefs)
|
||||
if pref_form.is_valid():
|
||||
pref_form.save()
|
||||
messages.success(request, 'Standardansicht gespeichert.')
|
||||
return redirect('settings')
|
||||
elif request.POST.get('action') == 'apikey':
|
||||
key_form = ApiKeyForm(request.POST)
|
||||
if key_form.is_valid():
|
||||
@@ -139,8 +228,10 @@ def settings_view(request):
|
||||
key_form.save_m2m()
|
||||
messages.success(request, f'API Key erzeugt: {api_key.token}')
|
||||
return redirect('settings')
|
||||
prefs, _ = UserPreference.objects.get_or_create(user=request.user)
|
||||
return render(request, 'core/settings.html', {
|
||||
'profile_form': UserSettingsForm(instance=request.user),
|
||||
'preference_form': UserPreferenceForm(instance=prefs),
|
||||
'key_form': ApiKeyForm(),
|
||||
'api_keys': request.user.api_keys.prefetch_related('tags'),
|
||||
})
|
||||
@@ -184,15 +275,28 @@ def edit_item(request, pk):
|
||||
form = EditItemForm(request.POST, request.FILES, instance=item)
|
||||
if form.is_valid():
|
||||
item = form.save()
|
||||
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
|
||||
item.sync_metadata()
|
||||
if request.FILES.get('files'):
|
||||
Attachment.objects.create(item=item, file=request.FILES['files'])
|
||||
response = render(request, 'core/_item.html', {'item': item})
|
||||
response = render(request, 'core/_item.html', {'item': item, 'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4})
|
||||
response['HX-Trigger'] = 'tagsChanged'
|
||||
return response
|
||||
return render(request, 'core/_edit_form.html', {'item': item, 'tags': Tag.objects.all()})
|
||||
|
||||
|
||||
@login_required
|
||||
def item_editor(request, pk):
|
||||
item = get_object_or_404(Item, pk=pk, owner=request.user)
|
||||
next_url = request.GET.get('next') or item.get_absolute_url()
|
||||
if request.method == 'POST':
|
||||
form = EditItemForm(request.POST, request.FILES, instance=item)
|
||||
if form.is_valid():
|
||||
item = form.save()
|
||||
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
|
||||
item.sync_metadata()
|
||||
return redirect(next_url)
|
||||
return render(request, 'core/item_editor.html', {'item': item, 'tags': Tag.objects.all(), 'next_url': next_url})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def delete_item(request, pk):
|
||||
@@ -211,6 +315,13 @@ def api_tags(request):
|
||||
return JsonResponse({'tags': list(Tag.objects.order_by('name').values_list('name', flat=True))})
|
||||
|
||||
|
||||
@login_required
|
||||
def api_attachment_suggestions(request):
|
||||
item = get_object_or_404(Item, pk=request.GET.get('item'), owner=request.user)
|
||||
data = [{'id': a.id, 'name': a.file.name.split('/')[-1], 'url': a.file.url} for a in item.attachments.all()]
|
||||
return JsonResponse({'attachments': data})
|
||||
|
||||
|
||||
@login_required
|
||||
def api_item_suggestions(request):
|
||||
q = request.GET.get('q', '').strip()
|
||||
|
||||
Reference in New Issue
Block a user