Add journal, editor, and overview improvements
This commit is contained in:
+2
-1
@@ -1,5 +1,5 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import ApiKey, Attachment, Item, Tag
|
from .models import ApiKey, Attachment, Item, Tag, UserPreference
|
||||||
|
|
||||||
|
|
||||||
class AttachmentInline(admin.TabularInline):
|
class AttachmentInline(admin.TabularInline):
|
||||||
@@ -22,3 +22,4 @@ class ApiKeyAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
|
|
||||||
admin.site.register(Tag)
|
admin.site.register(Tag)
|
||||||
|
admin.site.register(UserPreference)
|
||||||
|
|||||||
+30
-3
@@ -1,10 +1,22 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth.models import User
|
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):
|
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:
|
class Meta:
|
||||||
model = Item
|
model = Item
|
||||||
@@ -21,7 +33,7 @@ class QuickItemForm(forms.ModelForm):
|
|||||||
|
|
||||||
|
|
||||||
class EditItemForm(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:
|
class Meta:
|
||||||
model = Item
|
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']}
|
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 ApiKeyForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ApiKey
|
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'
|
TODO = 'todo', 'Aufgabe'
|
||||||
NOTE = 'note', 'Notiz'
|
NOTE = 'note', 'Notiz'
|
||||||
LINK = 'link', 'Link'
|
LINK = 'link', 'Link'
|
||||||
|
JOURNAL = 'journal', 'Journal'
|
||||||
|
|
||||||
class Visibility(models.TextChoices):
|
class Visibility(models.TextChoices):
|
||||||
PRIVATE = 'private', 'Privat'
|
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))
|
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):
|
class ApiKey(models.Model):
|
||||||
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='api_keys')
|
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='api_keys')
|
||||||
name = models.CharField(max_length=120, default='API Key')
|
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 %}
|
{% csrf_token %}
|
||||||
<div class="card-body vstack gap-2">
|
<div class="card-body vstack gap-2">
|
||||||
<div class="small text-muted">Typ: {{ item.get_kind_display }}</div>
|
<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>
|
<option value="public" {% if item.visibility == 'public' %}selected{% endif %}>Öffentlich</option>
|
||||||
</select></div>
|
</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 %}
|
{% 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>
|
</div>
|
||||||
{% if item.kind == 'link' %}<input class="form-control" type="url" name="url" value="{{ item.url }}" placeholder="URL">{% endif %}
|
{% 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>
|
<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">
|
<div class="d-flex gap-2">
|
||||||
<button class="btn btn-dark btn-sm">Speichern</button>
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
{% load markdown_extras %}
|
{% 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="card-body">
|
||||||
<div class="d-flex justify-content-between align-items-start gap-3">
|
<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">
|
<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>
|
<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 %}
|
{% 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>
|
||||||
<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.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.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.url %}<a href="{{ item.url }}" target="_blank" rel="noopener">{{ item.url }}</a>{% endif %}
|
||||||
@@ -17,13 +20,17 @@
|
|||||||
{% if item.attachments.all %}
|
{% 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="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">
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group btn-group-sm">
|
<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 %}
|
{% 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>
|
<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>
|
</div>
|
||||||
|
|||||||
@@ -9,29 +9,32 @@
|
|||||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
<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>
|
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
<style>
|
<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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
<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>
|
<script>
|
||||||
document.body.addEventListener('htmx:configRequest', e => {e.detail.headers['X-CSRFToken']='{{ csrf_token }}'});
|
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 {
|
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||[]})})},
|
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'}],
|
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.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'}]},
|
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)},
|
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)},
|
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)},
|
||||||
previewFile(e){let file=e.target.files[0];this.preview=file&&file.type.startsWith('image/')?URL.createObjectURL(file):null},
|
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)},
|
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||[]})}},
|
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;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)})}
|
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()}}}
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
{% extends 'core/base.html' %}
|
{% extends 'core/base.html' %}
|
||||||
{% block content %}
|
{% 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' %}
|
{% include 'core/_item.html' %}
|
||||||
{% if item.backlinks.all %}
|
{% 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>
|
<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 %}
|
{% endif %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener('keydown', function(event){
|
||||||
|
if(event.key === 'Escape') window.location.href = '{{ request.GET.next|default:"/"|escapejs }}';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
<div class="card item-card shadow-sm"><div class="card-body">
|
<div class="card item-card shadow-sm"><div class="card-body">
|
||||||
<div class="fw-semibold mb-2">Filter</div>
|
<div class="fw-semibold mb-2">Filter</div>
|
||||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
<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="{% 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 == '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="{% 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 == '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="{% 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 == 'link' %}btn-success{% else %}btn-outline-success{% endif %}" href="{% query_replace kind='link' %}">Links</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 == '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>
|
<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>
|
</div>
|
||||||
@@ -17,11 +17,22 @@
|
|||||||
{% if active_tag %}<input type="hidden" name="tag" value="{{ active_tag }}">{% endif %}
|
{% 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_kind %}<input type="hidden" name="kind" value="{{ active_kind }}">{% endif %}
|
||||||
{% if active_status %}<input type="hidden" name="status" value="{{ active_status }}">{% 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>
|
<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>
|
<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>
|
</div>
|
||||||
</form>
|
</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 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">
|
<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' %}
|
{% 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>
|
<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>
|
||||||
<div class="d-flex align-items-center gap-2 mt-2">
|
<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>
|
<button class="btn btn-dark px-4">Speichern</button>
|
||||||
</div>
|
</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>
|
<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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
function quickComposer(tags){
|
function quickComposer(tags, itemId=null){
|
||||||
return {
|
return {
|
||||||
open:false,
|
open:false,
|
||||||
text:'',
|
text:'',
|
||||||
@@ -56,6 +68,9 @@ function quickComposer(tags){
|
|||||||
currentEl:null,
|
currentEl:null,
|
||||||
preview:null,
|
preview:null,
|
||||||
linkSuggestions:[],
|
linkSuggestions:[],
|
||||||
|
attachmentSuggestions:[],
|
||||||
|
pendingFiles:[],
|
||||||
|
itemId:itemId,
|
||||||
tags:tags||[],
|
tags:tags||[],
|
||||||
init(){
|
init(){
|
||||||
document.body.addEventListener('htmx:afterRequest', (event) => {
|
document.body.addEventListener('htmx:afterRequest', (event) => {
|
||||||
@@ -68,18 +83,22 @@ function quickComposer(tags){
|
|||||||
commands:[
|
commands:[
|
||||||
{token:'/todo',label:'Aufgabe erstellen'},
|
{token:'/todo',label:'Aufgabe erstellen'},
|
||||||
{token:'/link',label:'Link speichern'},
|
{token:'/link',label:'Link speichern'},
|
||||||
|
{token:'/journal',label:'Journal-Eintrag'},
|
||||||
{token:'/public',label:'öffentlich'},
|
{token:'/public',label:'öffentlich'},
|
||||||
{token:'/private',label:'privat'},
|
{token:'/private',label:'privat'},
|
||||||
{token:'/code',label:'Markdown Codeblock einfügen'},
|
{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(){
|
get filtered(){
|
||||||
let q=this.query.toLowerCase();
|
let q=this.query.toLowerCase();
|
||||||
let list=q.startsWith('#')
|
let list=q.startsWith('#')
|
||||||
? this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q)).map(t=>({token:'#'+t,label:'Tag'}))
|
? this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q)).map(t=>({token:'#'+t,label:'Tag'}))
|
||||||
: q.startsWith('[[')
|
: q.startsWith('![[')
|
||||||
? this.linkSuggestions.map(i=>({token:`[[${i.id}]]`,label:`${i.kind}: ${i.label}`}))
|
? (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}`})))
|
||||||
: this.commands.filter(c=>!q||c.token.toLowerCase().startsWith(q));
|
: 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'}];
|
return list.length?list:[{token:this.query,label:'neu'}];
|
||||||
},
|
},
|
||||||
onInput(e){this.update(e.target)},
|
onInput(e){this.update(e.target)},
|
||||||
@@ -90,13 +109,16 @@ function quickComposer(tags){
|
|||||||
let ext=(file.type.split('/')[1]||'png').replace('jpeg','jpg');
|
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 named=new File([file], `paste-${new Date().toISOString().replace(/[:.]/g,'-')}.${ext}`, {type:file.type});
|
||||||
let dt=new DataTransfer();
|
let dt=new DataTransfer();
|
||||||
|
[...(this.$refs.fileInput.files||[])].forEach(f=>dt.items.add(f));
|
||||||
dt.items.add(named);
|
dt.items.add(named);
|
||||||
this.$refs.fileInput.files=dt.files;
|
this.$refs.fileInput.files=dt.files;
|
||||||
|
this.pendingFiles=[...dt.files].filter(f=>f.type.startsWith('image/'));
|
||||||
this.preview=URL.createObjectURL(named);
|
this.preview=URL.createObjectURL(named);
|
||||||
},
|
},
|
||||||
previewFile(e){
|
previewFile(e){
|
||||||
let file=e.target.files[0];
|
this.pendingFiles=[...e.target.files].filter(f=>f.type.startsWith('image/'));
|
||||||
this.preview=file&&file.type.startsWith('image/')?URL.createObjectURL(file):null;
|
let file=this.pendingFiles[0];
|
||||||
|
this.preview=file?URL.createObjectURL(file):null;
|
||||||
},
|
},
|
||||||
onKeydown(e){
|
onKeydown(e){
|
||||||
this.currentEl=e.target;
|
this.currentEl=e.target;
|
||||||
@@ -138,8 +160,10 @@ function quickComposer(tags){
|
|||||||
let fragment=(before.match(/(^|\s)([^\s]*)$/)||['','',''])[2];
|
let fragment=(before.match(/(^|\s)([^\s]*)$/)||['','',''])[2];
|
||||||
this.query=fragment;
|
this.query=fragment;
|
||||||
this.active=0;
|
this.active=0;
|
||||||
this.open=fragment.startsWith('/')||fragment.startsWith('#')||fragment.startsWith('[[');
|
this.open=fragment.startsWith('/')||fragment.startsWith('#')||fragment.startsWith('[[')||fragment.startsWith('![[');
|
||||||
if(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);
|
let q=fragment.slice(2);
|
||||||
fetch(`{% url "api_item_suggestions" %}?q=${encodeURIComponent(q)}`).then(r=>r.json()).then(data=>{this.linkSuggestions=data.items||[]});
|
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){
|
insert(token){
|
||||||
let el=this.currentEl || document.getElementById('id_content');
|
let el=this.currentEl || document.getElementById('id_content');
|
||||||
if(!el) return;
|
if(!el) return;
|
||||||
if(token==='/code') token='```\n\n```';
|
let isCode=token==='/code';
|
||||||
|
if(isCode) token='```\n\n```';
|
||||||
let pos=el.selectionStart;
|
let pos=el.selectionStart;
|
||||||
let before=el.value.slice(0,pos);
|
let before=el.value.slice(0,pos);
|
||||||
let after=el.value.slice(pos);
|
let after=el.value.slice(pos);
|
||||||
let match=before.match(/(^|\s)([^\s]*)$/);
|
let match=before.match(/(^|\s)([^\s]*)$/);
|
||||||
let start=match?pos-match[2].length:pos;
|
let start=match?pos-match[2].length:pos;
|
||||||
let prefix=start>0&&el.value[start-1]!==' '?' ':'';
|
let needsPrefix=start>0&&el.value[start-1]!=='\n'&&el.value[start-1]!==' ';
|
||||||
let value=el.value.slice(0,start)+prefix+token+' '+after;
|
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;
|
this.text=value;
|
||||||
el.value=value;
|
el.value=value;
|
||||||
this.open=false;
|
this.open=false;
|
||||||
this.$nextTick(()=>{
|
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.focus();
|
||||||
el.setSelectionRange(p,p);
|
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>
|
</div></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="col-lg-6">
|
<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">
|
<div class="card item-card shadow-sm mb-3"><div class="card-body">
|
||||||
<h2 class="h5 mb-3">API Key erzeugen</h2>
|
<h2 class="h5 mb-3">API Key erzeugen</h2>
|
||||||
<form method="post" class="vstack gap-3">
|
<form method="post" class="vstack gap-3">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import re
|
||||||
import bleach
|
import bleach
|
||||||
import markdown as md
|
import markdown as md
|
||||||
from django import template
|
from django import template
|
||||||
@@ -7,9 +8,10 @@ register = template.Library()
|
|||||||
|
|
||||||
ALLOWED_TAGS = set(bleach.sanitizer.ALLOWED_TAGS) | {
|
ALLOWED_TAGS = set(bleach.sanitizer.ALLOWED_TAGS) | {
|
||||||
'p', 'pre', 'code', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'ul', 'ol', 'li',
|
'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
|
@register.filter
|
||||||
@@ -18,6 +20,26 @@ def markdown(text):
|
|||||||
return mark_safe(bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS))
|
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
|
@register.filter
|
||||||
def is_image(file_name):
|
def is_image(file_name):
|
||||||
return str(file_name).lower().split('?')[0].endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'))
|
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('', views.index, name='index'),
|
||||||
path('login/', auth_views.LoginView.as_view(template_name='core/login.html'), name='login'),
|
path('login/', auth_views.LoginView.as_view(template_name='core/login.html'), name='login'),
|
||||||
path('logout/', auth_views.LogoutView.as_view(), name='logout'),
|
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/', views.settings_view, name='settings'),
|
||||||
path('settings/api-keys/<int:pk>/delete/', views.delete_api_key, name='delete_api_key'),
|
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>/', views.item_detail, name='item_detail'),
|
||||||
path('items/<int:pk>/card/', views.item_card, name='item_card'),
|
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>/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>/toggle/', views.toggle_done, name='toggle_done'),
|
||||||
path('items/<int:pk>/delete/', views.delete_item, name='delete_item'),
|
path('items/<int:pk>/delete/', views.delete_item, name='delete_item'),
|
||||||
path('tags/', views.tag_list, name='tag_list'),
|
path('tags/', views.tag_list, name='tag_list'),
|
||||||
path('api/tags/', views.api_tags, name='api_tags'),
|
path('api/tags/', views.api_tags, name='api_tags'),
|
||||||
path('api/item-suggestions/', views.api_item_suggestions, name='api_item_suggestions'),
|
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/', views.api_items, name='api_items'),
|
||||||
path('api/items/<int:pk>/', views.api_item_detail, name='api_item_detail'),
|
path('api/items/<int:pk>/', views.api_item_detail, name='api_item_detail'),
|
||||||
]
|
]
|
||||||
|
|||||||
+122
-11
@@ -1,6 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
from calendar import Calendar
|
||||||
|
from datetime import date
|
||||||
from html import unescape
|
from html import unescape
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
from django.contrib import messages
|
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.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.views.decorators.http import require_http_methods, require_POST
|
from django.views.decorators.http import require_http_methods, require_POST
|
||||||
from .forms import ApiKeyForm, EditItemForm, QuickItemForm, UserSettingsForm
|
from .forms import ApiKeyForm, EditItemForm, QuickItemForm, UserPreferenceForm, UserSettingsForm
|
||||||
from .models import ApiKey, Attachment, Item, Tag
|
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+')
|
URL_RE = re.compile(r'https?://\S+')
|
||||||
ONLY_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)
|
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)}
|
commands = {c.lower() for c in COMMAND_RE.findall(content)}
|
||||||
content = COMMAND_RE.sub('', content).strip()
|
content = COMMAND_RE.sub('', content).strip()
|
||||||
only_url = bool(ONLY_URL_RE.match(content))
|
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
|
visibility = Item.Visibility.PUBLIC if 'public' in commands else Item.Visibility.PRIVATE
|
||||||
url_match = URL_RE.search(content)
|
url_match = URL_RE.search(content)
|
||||||
url = url_match.group(0) if kind == Item.Kind.LINK and url_match else ''
|
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
|
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):
|
def visible_items(user, api_key=None):
|
||||||
qs = Item.objects.select_related('owner').prefetch_related('tags', 'attachments', 'linked_items')
|
qs = Item.objects.select_related('owner').prefetch_related('tags', 'attachments', 'linked_items')
|
||||||
if api_key:
|
if api_key:
|
||||||
@@ -86,9 +105,8 @@ def index(request):
|
|||||||
if active_tag and f'#{active_tag.lower()}' not in content.lower():
|
if active_tag and f'#{active_tag.lower()}' not in content.lower():
|
||||||
content = f'{content} #{active_tag}'
|
content = f'{content} #{active_tag}'
|
||||||
item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url)
|
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()
|
item.sync_metadata()
|
||||||
if request.FILES.get('files'):
|
|
||||||
Attachment.objects.create(item=item, file=request.FILES['files'])
|
|
||||||
if request.headers.get('HX-Request'):
|
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 = render(request, 'core/_create_response.html', {'item': item, 'tags': Tag.objects.all(), 'active_tag': request.GET.get('tag')})
|
||||||
response['HX-Trigger'] = 'tagsChanged'
|
response['HX-Trigger'] = 'tagsChanged'
|
||||||
@@ -97,6 +115,7 @@ def index(request):
|
|||||||
tag = request.GET.get('tag')
|
tag = request.GET.get('tag')
|
||||||
kind = request.GET.get('kind')
|
kind = request.GET.get('kind')
|
||||||
status = request.GET.get('status')
|
status = request.GET.get('status')
|
||||||
|
day = request.GET.get('day')
|
||||||
q = request.GET.get('q', '').strip()
|
q = request.GET.get('q', '').strip()
|
||||||
if q.startswith('#') and len(q) > 1:
|
if q.startswith('#') and len(q) > 1:
|
||||||
query = request.GET.copy()
|
query = request.GET.copy()
|
||||||
@@ -104,20 +123,83 @@ def index(request):
|
|||||||
query.pop('q', None)
|
query.pop('q', None)
|
||||||
return redirect(f'{request.path}?{query.urlencode()}')
|
return redirect(f'{request.path}?{query.urlencode()}')
|
||||||
items = visible_items(request.user)
|
items = visible_items(request.user)
|
||||||
|
applied_preferences = False
|
||||||
if tag:
|
if tag:
|
||||||
items = items.filter(tags__name=tag)
|
items = items.filter(tags__name=tag)
|
||||||
if kind in dict(Item.Kind.choices):
|
if kind in dict(Item.Kind.choices):
|
||||||
items = items.filter(kind=kind)
|
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':
|
if status == 'open':
|
||||||
items = items.filter(kind=Item.Kind.TODO, is_done=False)
|
items = items.filter(kind=Item.Kind.TODO, is_done=False)
|
||||||
elif status == 'archive':
|
elif status == 'archive':
|
||||||
items = items.filter(kind=Item.Kind.TODO, is_done=True)
|
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)
|
items = items.exclude(kind=Item.Kind.TODO, is_done=True)
|
||||||
if q:
|
if q:
|
||||||
items = items.filter(Q(content__icontains=q) | Q(url__icontains=q) | Q(tags__name__icontains=q)).distinct()
|
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', {
|
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()
|
form.save()
|
||||||
messages.success(request, 'Einstellungen gespeichert.')
|
messages.success(request, 'Einstellungen gespeichert.')
|
||||||
return redirect('settings')
|
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':
|
elif request.POST.get('action') == 'apikey':
|
||||||
key_form = ApiKeyForm(request.POST)
|
key_form = ApiKeyForm(request.POST)
|
||||||
if key_form.is_valid():
|
if key_form.is_valid():
|
||||||
@@ -139,8 +228,10 @@ def settings_view(request):
|
|||||||
key_form.save_m2m()
|
key_form.save_m2m()
|
||||||
messages.success(request, f'API Key erzeugt: {api_key.token}')
|
messages.success(request, f'API Key erzeugt: {api_key.token}')
|
||||||
return redirect('settings')
|
return redirect('settings')
|
||||||
|
prefs, _ = UserPreference.objects.get_or_create(user=request.user)
|
||||||
return render(request, 'core/settings.html', {
|
return render(request, 'core/settings.html', {
|
||||||
'profile_form': UserSettingsForm(instance=request.user),
|
'profile_form': UserSettingsForm(instance=request.user),
|
||||||
|
'preference_form': UserPreferenceForm(instance=prefs),
|
||||||
'key_form': ApiKeyForm(),
|
'key_form': ApiKeyForm(),
|
||||||
'api_keys': request.user.api_keys.prefetch_related('tags'),
|
'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)
|
form = EditItemForm(request.POST, request.FILES, instance=item)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
item = form.save()
|
item = form.save()
|
||||||
|
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
|
||||||
item.sync_metadata()
|
item.sync_metadata()
|
||||||
if request.FILES.get('files'):
|
response = render(request, 'core/_item.html', {'item': item, 'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4})
|
||||||
Attachment.objects.create(item=item, file=request.FILES['files'])
|
|
||||||
response = render(request, 'core/_item.html', {'item': item})
|
|
||||||
response['HX-Trigger'] = 'tagsChanged'
|
response['HX-Trigger'] = 'tagsChanged'
|
||||||
return response
|
return response
|
||||||
return render(request, 'core/_edit_form.html', {'item': item, 'tags': Tag.objects.all()})
|
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
|
@login_required
|
||||||
@require_POST
|
@require_POST
|
||||||
def delete_item(request, pk):
|
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))})
|
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
|
@login_required
|
||||||
def api_item_suggestions(request):
|
def api_item_suggestions(request):
|
||||||
q = request.GET.get('q', '').strip()
|
q = request.GET.get('q', '').strip()
|
||||||
|
|||||||
Reference in New Issue
Block a user