Initial my2dos Django app
This commit is contained in:
@@ -174,3 +174,6 @@ cython_debug/
|
|||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
|
||||||
|
# Uploaded files
|
||||||
|
/media/
|
||||||
|
|||||||
@@ -1,2 +1,37 @@
|
|||||||
# my2dos
|
# my2dos
|
||||||
|
|
||||||
|
Kleine Django/HTMX/Alpine.js-App für Aufgaben, Notizen und Links.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python manage.py migrate
|
||||||
|
python manage.py createsuperuser
|
||||||
|
python manage.py runserver
|
||||||
|
```
|
||||||
|
|
||||||
|
Login aktuell über `/admin/login/`, danach App unter `/`.
|
||||||
|
|
||||||
|
## Schnell-Erfassung
|
||||||
|
|
||||||
|
- `/todo` erstellt eine Aufgabe
|
||||||
|
- `/note` erstellt eine Notiz (Default)
|
||||||
|
- `/link` erstellt einen Link-Eintrag
|
||||||
|
- `/public` macht den Eintrag öffentlich, sonst privat
|
||||||
|
- `#tag` gruppiert Einträge nach Tags
|
||||||
|
- `[[note:1]]`, `[[todo:2]]` oder `[[3]]` verlinkt intern auf andere Einträge
|
||||||
|
- Markdown wird gerendert
|
||||||
|
- Anhänge können beim Erstellen hochgeladen werden
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
Authentifiziert per Django-Session:
|
||||||
|
|
||||||
|
- `GET /api/items/`
|
||||||
|
- `POST /api/items/` JSON z.B. `{ "content": "/todo Text #tag /public" }`
|
||||||
|
- `GET /api/items/<id>/`
|
||||||
|
- `PATCH /api/items/<id>/`
|
||||||
|
- `DELETE /api/items/<id>/`
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import ApiKey, Attachment, Item, Tag
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentInline(admin.TabularInline):
|
||||||
|
model = Attachment
|
||||||
|
extra = 0
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Item)
|
||||||
|
class ItemAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('id', 'kind', 'visibility', 'is_done', 'owner', 'created_at')
|
||||||
|
list_filter = ('kind', 'visibility', 'is_done', 'tags')
|
||||||
|
search_fields = ('content', 'url')
|
||||||
|
inlines = [AttachmentInline]
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ApiKey)
|
||||||
|
class ApiKeyAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('name', 'owner', 'is_active', 'created_at')
|
||||||
|
filter_horizontal = ('tags',)
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(Tag)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CoreConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'core'
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from .models import ApiKey, Attachment, Item
|
||||||
|
|
||||||
|
|
||||||
|
class QuickItemForm(forms.ModelForm):
|
||||||
|
files = forms.FileField(required=False, widget=forms.ClearableFileInput(attrs={'multiple': False}))
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Item
|
||||||
|
fields = ['content']
|
||||||
|
widgets = {'content': forms.Textarea(attrs={
|
||||||
|
'class': 'form-control form-control-lg',
|
||||||
|
'rows': 3,
|
||||||
|
'placeholder': 'Schreibe etwas … /todo /link /public #tag [[note:1]]',
|
||||||
|
'x-model': 'text',
|
||||||
|
'@keydown': 'onKeydown($event)',
|
||||||
|
'@input': 'onInput($event)',
|
||||||
|
'@paste': 'handlePaste($event)',
|
||||||
|
})}
|
||||||
|
|
||||||
|
|
||||||
|
class EditItemForm(forms.ModelForm):
|
||||||
|
files = forms.FileField(required=False, widget=forms.ClearableFileInput())
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Item
|
||||||
|
fields = ['content', 'comment', 'visibility', 'due_at', 'url']
|
||||||
|
widgets = {
|
||||||
|
'comment': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
|
||||||
|
'due_at': forms.DateTimeInput(attrs={'type': 'datetime-local'}, format='%Y-%m-%dT%H:%M'),
|
||||||
|
'url': forms.URLInput(attrs={'class': 'form-control', 'placeholder': 'URL'}),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields['due_at'].input_formats = ['%Y-%m-%dT%H:%M']
|
||||||
|
|
||||||
|
|
||||||
|
class UserSettingsForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ['first_name', 'last_name', 'email']
|
||||||
|
widgets = {field: forms.TextInput(attrs={'class': 'form-control'}) for field in ['first_name', 'last_name', 'email']}
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = ApiKey
|
||||||
|
fields = ['name', 'tags']
|
||||||
|
widgets = {
|
||||||
|
'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Name des API Keys'}),
|
||||||
|
'tags': forms.CheckboxSelectMultiple(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Attachment
|
||||||
|
fields = ['file']
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Generated for my2dos MVP
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Tag',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.SlugField(allow_unicode=True, max_length=80, unique=True)),
|
||||||
|
],
|
||||||
|
options={'ordering': ['name']},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Item',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('kind', models.CharField(choices=[('todo', 'Aufgabe'), ('note', 'Notiz'), ('link', 'Link')], default='note', max_length=10)),
|
||||||
|
('content', models.TextField()),
|
||||||
|
('visibility', models.CharField(choices=[('private', 'Privat'), ('public', 'Öffentlich')], default='private', max_length=10)),
|
||||||
|
('is_done', models.BooleanField(default=False)),
|
||||||
|
('url', models.URLField(blank=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('linked_items', models.ManyToManyField(blank=True, related_name='backlinks', to='core.item')),
|
||||||
|
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='items', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('tags', models.ManyToManyField(blank=True, related_name='items', to='core.tag')),
|
||||||
|
],
|
||||||
|
options={'ordering': ['-created_at']},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Attachment',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('file', models.FileField(upload_to='attachments/%Y/%m/')),
|
||||||
|
('uploaded_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('item', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='core.item')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [('core', '0001_initial')]
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='item',
|
||||||
|
name='due_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [('core', '0002_item_due_at'), migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ApiKey',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(default='API Key', max_length=120)),
|
||||||
|
('token', models.CharField(editable=False, max_length=80, unique=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('is_active', models.BooleanField(default=True)),
|
||||||
|
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_keys', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('tags', models.ManyToManyField(blank=True, related_name='api_keys', to='core.tag')),
|
||||||
|
],
|
||||||
|
options={'ordering': ['-created_at']},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [('core', '0003_apikey')]
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='item',
|
||||||
|
name='comment',
|
||||||
|
field=models.TextField(blank=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
TAG_RE = re.compile(r'(?<!\w)#([\wäöüÄÖÜß-]+)')
|
||||||
|
LINK_RE = re.compile(r'\[\[(todo|note|link)?:?(\d+)\]\]', re.I)
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(models.Model):
|
||||||
|
name = models.SlugField(max_length=80, unique=True, allow_unicode=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'#{self.name}'
|
||||||
|
|
||||||
|
|
||||||
|
class Item(models.Model):
|
||||||
|
class Kind(models.TextChoices):
|
||||||
|
TODO = 'todo', 'Aufgabe'
|
||||||
|
NOTE = 'note', 'Notiz'
|
||||||
|
LINK = 'link', 'Link'
|
||||||
|
|
||||||
|
class Visibility(models.TextChoices):
|
||||||
|
PRIVATE = 'private', 'Privat'
|
||||||
|
PUBLIC = 'public', 'Öffentlich'
|
||||||
|
|
||||||
|
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='items')
|
||||||
|
kind = models.CharField(max_length=10, choices=Kind.choices, default=Kind.NOTE)
|
||||||
|
content = models.TextField()
|
||||||
|
comment = models.TextField(blank=True)
|
||||||
|
visibility = models.CharField(max_length=10, choices=Visibility.choices, default=Visibility.PRIVATE)
|
||||||
|
is_done = models.BooleanField(default=False)
|
||||||
|
due_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
url = models.URLField(blank=True)
|
||||||
|
tags = models.ManyToManyField(Tag, blank=True, related_name='items')
|
||||||
|
linked_items = models.ManyToManyField('self', blank=True, symmetrical=False, related_name='backlinks')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.content[:80]
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse('item_detail', args=[self.pk])
|
||||||
|
|
||||||
|
def sync_metadata(self):
|
||||||
|
tag_names = {m.group(1).lower() for m in TAG_RE.finditer(self.content)}
|
||||||
|
tags = [Tag.objects.get_or_create(name=name)[0] for name in tag_names]
|
||||||
|
self.tags.set(tags)
|
||||||
|
ids = [int(m.group(2)) for m in LINK_RE.finditer(f'{self.content}\n{self.comment}')]
|
||||||
|
self.linked_items.set(Item.objects.filter(id__in=ids).exclude(id=self.id))
|
||||||
|
|
||||||
|
|
||||||
|
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')
|
||||||
|
token = models.CharField(max_length=80, unique=True, editable=False)
|
||||||
|
tags = models.ManyToManyField(Tag, blank=True, related_name='api_keys')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.token:
|
||||||
|
self.token = secrets.token_urlsafe(32)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class Attachment(models.Model):
|
||||||
|
item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name='attachments')
|
||||||
|
file = models.FileField(upload_to='attachments/%Y/%m/')
|
||||||
|
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.file.name
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{% include 'core/_item.html' %}
|
||||||
|
{% include 'core/_tags.html' with tags=tags oob=True %}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<form id="item-{{ item.id }}" class="card item-card shadow-sm position-relative" method="post" enctype="multipart/form-data" hx-post="{% url 'edit_item' item.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-target="#item-{{ item.id }}" hx-swap="outerHTML" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])" x-init="text=$el.querySelector('textarea').value">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="card-body vstack gap-2">
|
||||||
|
<div class="small text-muted">Typ: {{ item.get_kind_display }}</div>
|
||||||
|
<textarea class="form-control" name="content" rows="5" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.content }}</textarea>
|
||||||
|
<textarea class="form-control" name="comment" rows="3" placeholder="Kommentar, z.B. gleiche Aufgabe bereits erfasst [[12]]" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.comment }}</textarea>
|
||||||
|
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
|
||||||
|
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></template>
|
||||||
|
</div>
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-4"><select class="form-select" name="visibility">
|
||||||
|
<option value="private" {% if item.visibility == 'private' %}selected{% endif %}>Privat</option>
|
||||||
|
<option value="public" {% if item.visibility == 'public' %}selected{% endif %}>Öffentlich</option>
|
||||||
|
</select></div>
|
||||||
|
{% if item.kind == 'todo' %}<div class="col-md-4"><input class="form-control" type="datetime-local" name="due_at" value="{{ item.due_at|date:'Y-m-d\\TH:i' }}" title="Datum/Zeit für Todo"></div>{% endif %}
|
||||||
|
<div class="col-md-4"><input class="form-control" type="file" name="files" x-ref="fileInput" @change="previewFile($event)"></div>
|
||||||
|
</div>
|
||||||
|
{% if item.kind == 'link' %}<input class="form-control" type="url" name="url" value="{{ item.url }}" placeholder="URL">{% endif %}
|
||||||
|
<template x-if="preview"><div class="small"><span class="text-muted">Bild aus Zwischenablage:</span><br><img :src="preview" class="img-thumbnail mt-1" style="max-height:160px"></div></template>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-dark btn-sm">Speichern</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" hx-get="{% url 'item_card' item.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-target="#item-{{ item.id }}" hx-swap="outerHTML">Abbrechen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{% load markdown_extras %}
|
||||||
|
<article id="item-{{ item.id }}" class="card item-card shadow-sm {% if item.is_done %}done{% endif %}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start gap-3">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="mb-2 d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
<span class="badge {% if item.kind == 'todo' %}text-bg-primary{% elif item.kind == 'link' %}text-bg-success{% else %}text-bg-secondary{% endif %}">{{ item.get_kind_display }}</span>
|
||||||
|
<span class="badge text-bg-light">{{ item.get_visibility_display }}</span>
|
||||||
|
<a class="text-muted small" href="{{ item.get_absolute_url }}">#{{ item.id }}</a>
|
||||||
|
{% for tag in item.tags.all %}<a class="badge rounded-pill text-bg-light text-decoration-none tag" href="/?tag={{ tag.name }}">#{{ tag.name }}</a>{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="content">{{ item.content|markdown }}</div>
|
||||||
|
{% if item.comment %}<div class="border-start ps-2 mt-2 small content text-muted">{{ item.comment|markdown }}</div>{% endif %}
|
||||||
|
{% if item.due_at %}<div class="small text-warning-emphasis mt-2">Fällig: {{ item.due_at|date:'d.m.Y H:i' }}</div>{% endif %}
|
||||||
|
{% if item.url %}<a href="{{ item.url }}" target="_blank" rel="noopener">{{ item.url }}</a>{% endif %}
|
||||||
|
{% if item.linked_items.all %}<div class="small mt-2">Links: {% for linked in item.linked_items.all %}<a href="{{ linked.get_absolute_url }}">#{{ linked.id }}</a> {% endfor %}</div>{% endif %}
|
||||||
|
{% if item.attachments.all %}
|
||||||
|
<div class="small mt-2">Anhänge: {% for a in item.attachments.all %}<a href="{{ a.file.url }}">{{ a.file.name }}</a> {% endfor %}</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 mt-2">
|
||||||
|
{% for a in item.attachments.all %}{% if a.file.name|is_image %}<a href="{{ a.file.url }}" target="_blank"><img src="{{ a.file.url }}" class="img-thumbnail" style="max-width:220px;max-height:180px" alt="{{ a.file.name }}"></a>{% endif %}{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
{% if item.kind == 'todo' %}<button class="btn btn-outline-primary" hx-post="{% url 'toggle_done' item.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-target="#item-{{ item.id }}" hx-swap="outerHTML">✓</button>{% endif %}
|
||||||
|
<button class="btn btn-outline-secondary" hx-get="{% url 'edit_item' item.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-target="#item-{{ item.id }}" hx-swap="outerHTML">✎</button>
|
||||||
|
<button class="btn btn-outline-danger" hx-post="{% url 'delete_item' item.id %}" hx-target="#item-{{ item.id }}" hx-swap="delete">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{% load querystring %}
|
||||||
|
<div id="tag-list" class="d-flex flex-wrap gap-2"{% if oob %} hx-swap-oob="true"{% endif %}>
|
||||||
|
{% for tag in tags %}
|
||||||
|
{% if active_tag == tag.name %}
|
||||||
|
<a class="badge rounded-pill text-bg-dark text-decoration-none tag" href="{% query_replace tag=None %}">#{{ tag.name }} ×</a>
|
||||||
|
{% else %}
|
||||||
|
<a class="badge rounded-pill text-bg-light text-decoration-none tag" href="{% query_replace tag=tag.name %}">#{{ tag.name }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% empty %}<span class="text-muted small">Noch keine Tags</span>{% endfor %}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{% load static %}
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>my2dos</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||||
|
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
<style>
|
||||||
|
body{background:#f7f7f4}.shell{max-width:960px}.item-card{border:0;border-radius:1rem}.content p:last-child{margin-bottom:0}.tag{font-size:.85rem}.slash-menu{position:absolute;z-index:20;top:100%;left:0;width:18rem}.done{opacity:.6}.done .content{text-decoration:line-through}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar bg-white border-bottom sticky-top"><div class="container shell"><a class="navbar-brand fw-bold" href="/">my2dos</a><div class="d-flex align-items-center gap-3"><span class="text-muted small d-none d-sm-inline">Notizen · Aufgaben · Links</span>{% if user.is_authenticated %}<a class="small" href="{% url 'settings' %}">{{ user.get_full_name|default:user.username }}</a><form method="post" action="{% url 'logout' %}" class="m-0">{% csrf_token %}<button class="btn btn-sm btn-outline-secondary">Logout</button></form>{% else %}<a class="btn btn-sm btn-dark" href="{% url 'login' %}">Login</a>{% endif %}</div></div></nav>
|
||||||
|
<main class="container shell py-4">{% for message in messages %}<div class="alert alert-{{ message.tags|default:'info' }}">{{ message }}</div>{% endfor %}{% block content %}{% endblock %}</main>
|
||||||
|
<script>
|
||||||
|
document.body.addEventListener('htmx:configRequest', e => {e.detail.headers['X-CSRFToken']='{{ csrf_token }}'});
|
||||||
|
window.quickComposer = window.quickComposer || function(tags){
|
||||||
|
return {
|
||||||
|
open:false,text:'',active:0,query:'',currentEl:null,preview:null,linkSuggestions:[],tags:tags||[],
|
||||||
|
init(){document.body.addEventListener('tagsChanged',()=>{fetch('/api/tags/').then(r=>r.json()).then(d=>{this.tags=d.tags||[]})})},
|
||||||
|
commands:[{token:'/todo',label:'Aufgabe erstellen'},{token:'/link',label:'Link speichern'},{token:'/public',label:'öffentlich'},{token:'/private',label:'privat'},{token:'/code',label:'Markdown Codeblock einfügen'},{token:'[[',label:'interne Aufgabe/Notiz suchen'}],
|
||||||
|
get filtered(){let q=this.query.toLowerCase();let list=q.startsWith('#')?this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q)).map(t=>({token:'#'+t,label:'Tag'})):q.startsWith('[[')?this.linkSuggestions.map(i=>({token:`[[${i.id}]]`,label:`${i.kind}: ${i.label}`})):this.commands.filter(c=>!q||c.token.toLowerCase().startsWith(q));return list.length?list:[{token:this.query,label:'neu'}]},
|
||||||
|
onInput(e){this.update(e.target)},
|
||||||
|
handlePaste(e){let item=[...(e.clipboardData?.items||[])].find(i=>i.kind==='file'&&i.type.startsWith('image/'));if(!item||!this.$refs.fileInput)return;let file=item.getAsFile();let ext=(file.type.split('/')[1]||'png').replace('jpeg','jpg');let named=new File([file],`paste-${new Date().toISOString().replace(/[:.]/g,'-')}.${ext}`,{type:file.type});let dt=new DataTransfer();dt.items.add(named);this.$refs.fileInput.files=dt.files;this.preview=URL.createObjectURL(named)},
|
||||||
|
previewFile(e){let file=e.target.files[0];this.preview=file&&file.type.startsWith('image/')?URL.createObjectURL(file):null},
|
||||||
|
onKeydown(e){this.currentEl=e.target;if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){e.preventDefault();this.open=false;e.target.form.requestSubmit();return}if(this.open&&e.key==='ArrowDown'){e.preventDefault();this.active=(this.active+1)%this.filtered.length;return}if(this.open&&e.key==='ArrowUp'){e.preventDefault();this.active=(this.active-1+this.filtered.length)%this.filtered.length;return}if(this.open&&(e.key==='Enter'||e.key==='Tab')&&!e.shiftKey){e.preventDefault();this.insert(this.filtered[this.active].token);return}if(e.key==='Escape'){this.open=false;return}if(e.key===' '){this.open=false;return}setTimeout(()=>this.update(e.target),0)},
|
||||||
|
update(el){this.currentEl=el;this.text=el.value;let before=el.value.slice(0,el.selectionStart);let fragment=(before.match(/(^|\s)([^\s]*)$/)||['','',''])[2];this.query=fragment;this.active=0;this.open=fragment.startsWith('/')||fragment.startsWith('#')||fragment.startsWith('[[');if(fragment.startsWith('[[')){fetch(`/api/item-suggestions/?q=${encodeURIComponent(fragment.slice(2))}`).then(r=>r.json()).then(d=>{this.linkSuggestions=d.items||[]})}},
|
||||||
|
insert(token){let el=this.currentEl||this.$el.querySelector('textarea');if(!el)return;if(token==='/code')token='```\n\n```';let pos=el.selectionStart;let before=el.value.slice(0,pos);let after=el.value.slice(pos);let match=before.match(/(^|\s)([^\s]*)$/);let start=match?pos-match[2].length:pos;let prefix=start>0&&el.value[start-1]!==' '?' ':'';let value=el.value.slice(0,start)+prefix+token+' '+after;this.text=value;el.value=value;this.open=false;this.$nextTick(()=>{let p=start+prefix.length+token.length+1;el.focus();el.setSelectionRange(p,p)})}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.searchBox = function(tags){return{open:false,active:0,q:'',tags:tags||[],get filtered(){let q=this.q.toLowerCase();return this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q))},onInput(e){this.q=e.target.value;this.open=this.q.startsWith('#')},onKeydown(e){if(!this.open)return;if(e.key==='ArrowDown'){e.preventDefault();this.active=(this.active+1)%Math.max(this.filtered.length,1)}else if(e.key==='ArrowUp'){e.preventDefault();this.active=(this.active-1+Math.max(this.filtered.length,1))%Math.max(this.filtered.length,1)}else if(e.key==='Enter'||e.key==='Tab'){if(this.filtered.length){e.preventDefault();this.select(this.filtered[this.active])}}else if(e.key==='Escape'){this.open=false}},select(tag){let url=new URL(window.location.href);url.searchParams.set('tag',tag);url.searchParams.delete('q');window.location.href=url.pathname+'?'+url.searchParams.toString()}}}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{% extends 'core/base.html' %}
|
||||||
|
{% block content %}
|
||||||
|
<a class="btn btn-sm btn-outline-secondary mb-3" href="/">← zurück</a>
|
||||||
|
{% include 'core/_item.html' %}
|
||||||
|
{% if item.backlinks.all %}
|
||||||
|
<div class="mt-4"><h5>Backlinks</h5><div class="vstack gap-3">{% for item in item.backlinks.all %}{% include 'core/_item.html' %}{% endfor %}</div></div>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
{% extends 'core/base.html' %}
|
||||||
|
{% load querystring %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row g-4">
|
||||||
|
<aside class="col-lg-3 order-lg-2">
|
||||||
|
<div class="card item-card shadow-sm"><div class="card-body">
|
||||||
|
<div class="fw-semibold mb-2">Filter</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||||
|
<a class="btn btn-sm {% if not active_kind and not active_status %}btn-dark{% else %}btn-outline-dark{% endif %}" href="{% query_replace kind=None status=None %}">Alle</a>
|
||||||
|
<a class="btn btn-sm {% if active_kind == 'todo' %}btn-primary{% else %}btn-outline-primary{% endif %}" href="{% query_replace kind='todo' %}">Todos</a>
|
||||||
|
<a class="btn btn-sm {% if active_kind == 'note' %}btn-secondary{% else %}btn-outline-secondary{% endif %}" href="{% query_replace kind='note' %}">Notizen</a>
|
||||||
|
<a class="btn btn-sm {% if active_kind == 'link' %}btn-success{% else %}btn-outline-success{% endif %}" href="{% query_replace kind='link' %}">Links</a>
|
||||||
|
<a class="btn btn-sm {% if active_status == 'open' %}btn-warning{% else %}btn-outline-warning{% endif %}" href="{% if active_status == 'open' %}{% query_replace status=None %}{% else %}{% query_replace status='open' %}{% endif %}">Unerledigt{% if active_status == 'open' %} ×{% endif %}</a>
|
||||||
|
<a class="btn btn-sm {% if active_status == 'archive' %}btn-dark{% else %}btn-outline-dark{% endif %}" href="{% if active_status == 'archive' %}{% query_replace status=None %}{% else %}{% query_replace status='archive' %}{% endif %}">Archiv{% if active_status == 'archive' %} ×{% endif %}</a>
|
||||||
|
</div>
|
||||||
|
<form class="mb-3 position-relative" method="get" x-data="searchBox([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])" x-on:keydown.window="if(($event.ctrlKey||$event.metaKey)&&$event.key==='f'){ $event.preventDefault(); $refs.search.focus(); $refs.search.select(); }">
|
||||||
|
{% if active_tag %}<input type="hidden" name="tag" value="{{ active_tag }}">{% endif %}
|
||||||
|
{% if active_kind %}<input type="hidden" name="kind" value="{{ active_kind }}">{% endif %}
|
||||||
|
{% if active_status %}<input type="hidden" name="status" value="{{ active_status }}">{% endif %}
|
||||||
|
<input x-ref="search" class="form-control form-control-sm" name="q" value="{{ q }}" placeholder="Suche (Ctrl+F, # für Tags)" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" @input="onInput($event)" @keydown="onKeydown($event)">
|
||||||
|
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
|
||||||
|
<template x-for="(tag, idx) in filtered" :key="tag"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="select(tag)"><strong x-text="'#'+tag"></strong></button></template>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div class="fw-semibold mb-2">Tags</div>
|
||||||
|
<div hx-get="{% url 'tag_list' %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-trigger="tagsChanged from:body" hx-target="#tag-list" hx-swap="outerHTML">
|
||||||
|
{% include 'core/_tags.html' %}
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
</aside>
|
||||||
|
<section class="col-lg-9">
|
||||||
|
<form class="position-relative mb-4" method="post" enctype="multipart/form-data" hx-post="{{ request.get_full_path }}" hx-target="#items" hx-swap="afterbegin" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])" x-on:htmx:after-request="if($event.detail.successful){text='';open=false;$el.reset()}">
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.content }}
|
||||||
|
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
|
||||||
|
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></template>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2 mt-2">
|
||||||
|
<input class="form-control" type="file" name="files" x-ref="fileInput" @change="previewFile($event)">
|
||||||
|
<button class="btn btn-dark px-4">Speichern</button>
|
||||||
|
</div>
|
||||||
|
<template x-if="preview"><div class="mt-2 small"><span class="text-muted">Bild aus Zwischenablage:</span><br><img :src="preview" class="img-thumbnail mt-1" style="max-height:160px"></div></template>
|
||||||
|
</form>
|
||||||
|
<div id="items" class="vstack gap-3">
|
||||||
|
{% for item in items %}{% include 'core/_item.html' %}{% empty %}<div class="text-center text-muted py-5">Noch nichts vorhanden.</div>{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
function quickComposer(tags){
|
||||||
|
return {
|
||||||
|
open:false,
|
||||||
|
text:'',
|
||||||
|
active:0,
|
||||||
|
query:'',
|
||||||
|
currentEl:null,
|
||||||
|
preview:null,
|
||||||
|
linkSuggestions:[],
|
||||||
|
tags:tags||[],
|
||||||
|
init(){
|
||||||
|
document.body.addEventListener('htmx:afterRequest', (event) => {
|
||||||
|
if(event.detail.successful && event.target === this.$el){ this.preview=null; }
|
||||||
|
});
|
||||||
|
document.body.addEventListener('tagsChanged', () => {
|
||||||
|
fetch('{% url "api_tags" %}').then(r=>r.json()).then(data=>{this.tags=data.tags||[]});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
commands:[
|
||||||
|
{token:'/todo',label:'Aufgabe erstellen'},
|
||||||
|
{token:'/link',label:'Link speichern'},
|
||||||
|
{token:'/public',label:'öffentlich'},
|
||||||
|
{token:'/private',label:'privat'},
|
||||||
|
{token:'/code',label:'Markdown Codeblock einfügen'},
|
||||||
|
{token:'[[',label:'interne Aufgabe/Notiz suchen'}
|
||||||
|
],
|
||||||
|
get filtered(){
|
||||||
|
let q=this.query.toLowerCase();
|
||||||
|
let list=q.startsWith('#')
|
||||||
|
? this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q)).map(t=>({token:'#'+t,label:'Tag'}))
|
||||||
|
: q.startsWith('[[')
|
||||||
|
? this.linkSuggestions.map(i=>({token:`[[${i.id}]]`,label:`${i.kind}: ${i.label}`}))
|
||||||
|
: this.commands.filter(c=>!q||c.token.toLowerCase().startsWith(q));
|
||||||
|
return list.length?list:[{token:this.query,label:'neu'}];
|
||||||
|
},
|
||||||
|
onInput(e){this.update(e.target)},
|
||||||
|
handlePaste(e){
|
||||||
|
let item=[...(e.clipboardData?.items||[])].find(i=>i.kind==='file'&&i.type.startsWith('image/'));
|
||||||
|
if(!item) return;
|
||||||
|
let file=item.getAsFile();
|
||||||
|
let ext=(file.type.split('/')[1]||'png').replace('jpeg','jpg');
|
||||||
|
let named=new File([file], `paste-${new Date().toISOString().replace(/[:.]/g,'-')}.${ext}`, {type:file.type});
|
||||||
|
let dt=new DataTransfer();
|
||||||
|
dt.items.add(named);
|
||||||
|
this.$refs.fileInput.files=dt.files;
|
||||||
|
this.preview=URL.createObjectURL(named);
|
||||||
|
},
|
||||||
|
previewFile(e){
|
||||||
|
let file=e.target.files[0];
|
||||||
|
this.preview=file&&file.type.startsWith('image/')?URL.createObjectURL(file):null;
|
||||||
|
},
|
||||||
|
onKeydown(e){
|
||||||
|
this.currentEl=e.target;
|
||||||
|
if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){
|
||||||
|
e.preventDefault();
|
||||||
|
this.open=false;
|
||||||
|
e.target.form.requestSubmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(this.open&&e.key==='ArrowDown'){
|
||||||
|
e.preventDefault();
|
||||||
|
this.active=(this.active+1)%this.filtered.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(this.open&&e.key==='ArrowUp'){
|
||||||
|
e.preventDefault();
|
||||||
|
this.active=(this.active-1+this.filtered.length)%this.filtered.length;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(this.open&&(e.key==='Enter'||e.key==='Tab')&&!e.shiftKey){
|
||||||
|
e.preventDefault();
|
||||||
|
this.insert(this.filtered[this.active].token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(e.key==='Escape'){
|
||||||
|
this.open=false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(e.key===' '){
|
||||||
|
this.open=false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(()=>this.update(e.target),0);
|
||||||
|
},
|
||||||
|
update(el){
|
||||||
|
this.currentEl=el;
|
||||||
|
this.text=el.value;
|
||||||
|
let before=el.value.slice(0,el.selectionStart);
|
||||||
|
let fragment=(before.match(/(^|\s)([^\s]*)$/)||['','',''])[2];
|
||||||
|
this.query=fragment;
|
||||||
|
this.active=0;
|
||||||
|
this.open=fragment.startsWith('/')||fragment.startsWith('#')||fragment.startsWith('[[');
|
||||||
|
if(fragment.startsWith('[[')){
|
||||||
|
let q=fragment.slice(2);
|
||||||
|
fetch(`{% url "api_item_suggestions" %}?q=${encodeURIComponent(q)}`).then(r=>r.json()).then(data=>{this.linkSuggestions=data.items||[]});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
insert(token){
|
||||||
|
let el=this.currentEl || document.getElementById('id_content');
|
||||||
|
if(!el) return;
|
||||||
|
if(token==='/code') token='```\n\n```';
|
||||||
|
let pos=el.selectionStart;
|
||||||
|
let before=el.value.slice(0,pos);
|
||||||
|
let after=el.value.slice(pos);
|
||||||
|
let match=before.match(/(^|\s)([^\s]*)$/);
|
||||||
|
let start=match?pos-match[2].length:pos;
|
||||||
|
let prefix=start>0&&el.value[start-1]!==' '?' ':'';
|
||||||
|
let value=el.value.slice(0,start)+prefix+token+' '+after;
|
||||||
|
this.text=value;
|
||||||
|
el.value=value;
|
||||||
|
this.open=false;
|
||||||
|
this.$nextTick(()=>{
|
||||||
|
let p=start+prefix.length+token.length+1;
|
||||||
|
el.focus();
|
||||||
|
el.setSelectionRange(p,p);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{% extends 'core/base.html' %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center"><div class="col-md-5">
|
||||||
|
<div class="card item-card shadow-sm"><div class="card-body">
|
||||||
|
<h1 class="h4 mb-3">Anmelden</h1>
|
||||||
|
{% if form.errors %}<div class="alert alert-danger">Benutzername oder Passwort ist falsch.</div>{% endif %}
|
||||||
|
<form method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="mb-3"><label class="form-label">Benutzername</label><input class="form-control" name="username" autofocus></div>
|
||||||
|
<div class="mb-3"><label class="form-label">Passwort</label><input class="form-control" type="password" name="password"></div>
|
||||||
|
<button class="btn btn-dark w-100">Login</button>
|
||||||
|
</form>
|
||||||
|
</div></div>
|
||||||
|
</div></div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{% extends 'core/base.html' %}
|
||||||
|
{% block content %}
|
||||||
|
<a class="btn btn-sm btn-outline-secondary mb-3" href="/">← zurück</a>
|
||||||
|
<div class="row g-4">
|
||||||
|
<section class="col-lg-6">
|
||||||
|
<div class="card item-card shadow-sm"><div class="card-body">
|
||||||
|
<h1 class="h4 mb-3">Benutzereinstellungen</h1>
|
||||||
|
<form method="post" class="vstack gap-3">
|
||||||
|
{% csrf_token %}<input type="hidden" name="action" value="profile">
|
||||||
|
<div><label class="form-label">Vorname</label>{{ profile_form.first_name }}</div>
|
||||||
|
<div><label class="form-label">Name</label>{{ profile_form.last_name }}</div>
|
||||||
|
<div><label class="form-label">E-Mail</label>{{ profile_form.email }}</div>
|
||||||
|
<button class="btn btn-dark">Speichern</button>
|
||||||
|
</form>
|
||||||
|
</div></div>
|
||||||
|
</section>
|
||||||
|
<section class="col-lg-6">
|
||||||
|
<div class="card item-card shadow-sm mb-3"><div class="card-body">
|
||||||
|
<h2 class="h5 mb-3">API Key erzeugen</h2>
|
||||||
|
<form method="post" class="vstack gap-3">
|
||||||
|
{% csrf_token %}<input type="hidden" name="action" value="apikey">
|
||||||
|
<div><label class="form-label">Name</label>{{ key_form.name }}</div>
|
||||||
|
<div><label class="form-label">Auf Tags beschränken</label><div class="small text-muted mb-2">Keine Auswahl = Zugriff auf alle eigenen Einträge.</div>{{ key_form.tags }}</div>
|
||||||
|
<button class="btn btn-dark">API Key generieren</button>
|
||||||
|
</form>
|
||||||
|
</div></div>
|
||||||
|
<div class="card item-card shadow-sm"><div class="card-body">
|
||||||
|
<h2 class="h5 mb-3">API Keys</h2>
|
||||||
|
<div class="vstack gap-2">
|
||||||
|
{% for key in api_keys %}
|
||||||
|
<div class="border rounded p-2">
|
||||||
|
<div class="d-flex justify-content-between gap-2"><strong>{{ key.name }}</strong><form method="post" action="{% url 'delete_api_key' key.id %}">{% csrf_token %}<button class="btn btn-sm btn-outline-danger">Löschen</button></form></div>
|
||||||
|
<code class="small user-select-all">{{ key.token }}</code>
|
||||||
|
<div class="small text-muted">Tags: {% for tag in key.tags.all %}#{{ tag.name }} {% empty %}alle{% endfor %}</div>
|
||||||
|
</div>
|
||||||
|
{% empty %}<span class="text-muted">Noch keine API Keys.</span>{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import bleach
|
||||||
|
import markdown as md
|
||||||
|
from django import template
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
ALLOWED_ATTRS = {'a': ['href', 'title', 'rel'], 'code': ['class']}
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def markdown(text):
|
||||||
|
html = md.markdown(text or '', extensions=['extra', 'sane_lists', 'nl2br'])
|
||||||
|
return mark_safe(bleach.clean(html, tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS))
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def is_image(file_name):
|
||||||
|
return str(file_name).lower().split('?')[0].endswith(('.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg'))
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from django import template
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag(takes_context=True)
|
||||||
|
def query_replace(context, **kwargs):
|
||||||
|
query = context['request'].GET.copy()
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if value is None or value == '':
|
||||||
|
query.pop(key, None)
|
||||||
|
else:
|
||||||
|
query[key] = value
|
||||||
|
encoded = query.urlencode()
|
||||||
|
return f'?{encoded}' if encoded else '?'
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from django.contrib.auth import views as auth_views
|
||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
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('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>/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/items/', views.api_items, name='api_items'),
|
||||||
|
path('api/items/<int:pk>/', views.api_item_detail, name='api_item_detail'),
|
||||||
|
]
|
||||||
+278
@@ -0,0 +1,278 @@
|
|||||||
|
import json
|
||||||
|
import re
|
||||||
|
from functools import wraps
|
||||||
|
from html import unescape
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.db.models import Q
|
||||||
|
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
|
||||||
|
|
||||||
|
COMMAND_RE = re.compile(r'/(todo|note|link|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)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_page_title(url):
|
||||||
|
try:
|
||||||
|
request = Request(url, headers={'User-Agent': 'my2dos/1.0'})
|
||||||
|
with urlopen(request, timeout=4) as response:
|
||||||
|
html = response.read(200_000).decode(response.headers.get_content_charset() or 'utf-8', errors='ignore')
|
||||||
|
match = TITLE_RE.search(html)
|
||||||
|
if match:
|
||||||
|
return re.sub(r'\s+', ' ', unescape(match.group(1))).strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ''
|
||||||
|
|
||||||
|
|
||||||
|
def parse_quick_content(raw):
|
||||||
|
content = raw.strip()
|
||||||
|
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
|
||||||
|
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 ''
|
||||||
|
if only_url:
|
||||||
|
content = fetch_page_title(url) or url
|
||||||
|
return kind, visibility, content, url
|
||||||
|
|
||||||
|
|
||||||
|
def visible_items(user, api_key=None):
|
||||||
|
qs = Item.objects.select_related('owner').prefetch_related('tags', 'attachments', 'linked_items')
|
||||||
|
if api_key:
|
||||||
|
qs = qs.filter(owner=api_key.owner)
|
||||||
|
allowed_tags = api_key.tags.all()
|
||||||
|
if allowed_tags.exists():
|
||||||
|
qs = qs.filter(tags__in=allowed_tags).distinct()
|
||||||
|
return qs
|
||||||
|
if user.is_authenticated:
|
||||||
|
return qs.filter(Q(owner=user) | Q(visibility=Item.Visibility.PUBLIC))
|
||||||
|
return qs.filter(visibility=Item.Visibility.PUBLIC)
|
||||||
|
|
||||||
|
|
||||||
|
def get_api_key(request):
|
||||||
|
token = request.headers.get('X-Api-Key') or request.headers.get('Authorization', '').removeprefix('Bearer ').strip()
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
return ApiKey.objects.filter(token=token, is_active=True).select_related('owner').prefetch_related('tags').first()
|
||||||
|
|
||||||
|
|
||||||
|
def api_auth_required(view):
|
||||||
|
@wraps(view)
|
||||||
|
def wrapper(request, *args, **kwargs):
|
||||||
|
request.api_key = get_api_key(request)
|
||||||
|
if request.user.is_authenticated or request.api_key:
|
||||||
|
return view(request, *args, **kwargs)
|
||||||
|
return JsonResponse({'error': 'authentication required'}, status=401)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def index(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'])
|
||||||
|
active_tag = request.GET.get('tag')
|
||||||
|
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)
|
||||||
|
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'
|
||||||
|
return response
|
||||||
|
return redirect('index')
|
||||||
|
tag = request.GET.get('tag')
|
||||||
|
kind = request.GET.get('kind')
|
||||||
|
status = request.GET.get('status')
|
||||||
|
q = request.GET.get('q', '').strip()
|
||||||
|
if q.startswith('#') and len(q) > 1:
|
||||||
|
query = request.GET.copy()
|
||||||
|
query['tag'] = q[1:].strip().lower()
|
||||||
|
query.pop('q', None)
|
||||||
|
return redirect(f'{request.path}?{query.urlencode()}')
|
||||||
|
items = visible_items(request.user)
|
||||||
|
if tag:
|
||||||
|
items = items.filter(tags__name=tag)
|
||||||
|
if kind in dict(Item.Kind.choices):
|
||||||
|
items = items.filter(kind=kind)
|
||||||
|
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:
|
||||||
|
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()
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def settings_view(request):
|
||||||
|
if request.method == 'POST':
|
||||||
|
if request.POST.get('action') == 'profile':
|
||||||
|
form = UserSettingsForm(request.POST, instance=request.user)
|
||||||
|
if form.is_valid():
|
||||||
|
form.save()
|
||||||
|
messages.success(request, 'Einstellungen gespeichert.')
|
||||||
|
return redirect('settings')
|
||||||
|
elif request.POST.get('action') == 'apikey':
|
||||||
|
key_form = ApiKeyForm(request.POST)
|
||||||
|
if key_form.is_valid():
|
||||||
|
api_key = key_form.save(commit=False)
|
||||||
|
api_key.owner = request.user
|
||||||
|
api_key.save()
|
||||||
|
key_form.save_m2m()
|
||||||
|
messages.success(request, f'API Key erzeugt: {api_key.token}')
|
||||||
|
return redirect('settings')
|
||||||
|
return render(request, 'core/settings.html', {
|
||||||
|
'profile_form': UserSettingsForm(instance=request.user),
|
||||||
|
'key_form': ApiKeyForm(),
|
||||||
|
'api_keys': request.user.api_keys.prefetch_related('tags'),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def delete_api_key(request, pk):
|
||||||
|
api_key = get_object_or_404(ApiKey, pk=pk, owner=request.user)
|
||||||
|
api_key.delete()
|
||||||
|
return redirect('settings')
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def item_detail(request, pk):
|
||||||
|
item = get_object_or_404(visible_items(request.user), pk=pk)
|
||||||
|
return render(request, 'core/detail.html', {'item': item})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def toggle_done(request, pk):
|
||||||
|
item = get_object_or_404(Item, pk=pk, owner=request.user, kind=Item.Kind.TODO)
|
||||||
|
item.is_done = not item.is_done
|
||||||
|
item.save(update_fields=['is_done', 'updated_at'])
|
||||||
|
if request.headers.get('HX-Request') and item.is_done and request.GET.get('status') != 'archive':
|
||||||
|
return HttpResponse('')
|
||||||
|
return render(request, 'core/_item.html', {'item': item})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def item_card(request, pk):
|
||||||
|
item = get_object_or_404(visible_items(request.user), pk=pk)
|
||||||
|
return render(request, 'core/_item.html', {'item': item})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def edit_item(request, pk):
|
||||||
|
item = get_object_or_404(Item, pk=pk, owner=request.user)
|
||||||
|
if request.method == 'POST':
|
||||||
|
form = EditItemForm(request.POST, request.FILES, instance=item)
|
||||||
|
if form.is_valid():
|
||||||
|
item = form.save()
|
||||||
|
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['HX-Trigger'] = 'tagsChanged'
|
||||||
|
return response
|
||||||
|
return render(request, 'core/_edit_form.html', {'item': item, 'tags': Tag.objects.all()})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def delete_item(request, pk):
|
||||||
|
item = get_object_or_404(Item, pk=pk, owner=request.user)
|
||||||
|
item.delete()
|
||||||
|
return JsonResponse({'ok': True})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def tag_list(request):
|
||||||
|
return render(request, 'core/_tags.html', {'tags': Tag.objects.all(), 'active_tag': request.GET.get('tag')})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def api_tags(request):
|
||||||
|
return JsonResponse({'tags': list(Tag.objects.order_by('name').values_list('name', flat=True))})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def api_item_suggestions(request):
|
||||||
|
q = request.GET.get('q', '').strip()
|
||||||
|
items = visible_items(request.user)
|
||||||
|
if q:
|
||||||
|
items = items.filter(Q(content__icontains=q) | Q(tags__name__icontains=q)).distinct()
|
||||||
|
data = [{'id': i.id, 'kind': i.kind, 'label': i.content[:80]} for i in items[:10]]
|
||||||
|
return JsonResponse({'items': data})
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_item(item):
|
||||||
|
return {
|
||||||
|
'id': item.id,
|
||||||
|
'kind': item.kind,
|
||||||
|
'content': item.content,
|
||||||
|
'comment': item.comment,
|
||||||
|
'visibility': item.visibility,
|
||||||
|
'is_done': item.is_done,
|
||||||
|
'due_at': item.due_at.isoformat() if item.due_at else None,
|
||||||
|
'url': item.url,
|
||||||
|
'tags': [t.name for t in item.tags.all()],
|
||||||
|
'linked_items': [i.id for i in item.linked_items.all()],
|
||||||
|
'attachments': [a.file.url for a in item.attachments.all()],
|
||||||
|
'created_at': item.created_at.isoformat(),
|
||||||
|
'updated_at': item.updated_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@api_auth_required
|
||||||
|
@require_http_methods(['GET', 'POST'])
|
||||||
|
def api_items(request):
|
||||||
|
user = request.api_key.owner if request.api_key else request.user
|
||||||
|
if request.method == 'GET':
|
||||||
|
return JsonResponse({'items': [serialize_item(i) for i in visible_items(user, request.api_key)[:200]]})
|
||||||
|
data = json.loads(request.body or '{}') if request.content_type == 'application/json' else request.POST
|
||||||
|
raw = data.get('content', '')
|
||||||
|
kind, visibility, content, url = parse_quick_content(raw)
|
||||||
|
item = Item.objects.create(owner=user, kind=data.get('kind') or kind, visibility=data.get('visibility') or visibility, content=content, url=data.get('url') or url)
|
||||||
|
item.sync_metadata()
|
||||||
|
return JsonResponse(serialize_item(item), status=201)
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
@api_auth_required
|
||||||
|
@require_http_methods(['GET', 'PATCH', 'DELETE'])
|
||||||
|
def api_item_detail(request, pk):
|
||||||
|
user = request.api_key.owner if request.api_key else request.user
|
||||||
|
item = get_object_or_404(visible_items(user, request.api_key), pk=pk)
|
||||||
|
if request.method == 'GET':
|
||||||
|
return JsonResponse(serialize_item(item))
|
||||||
|
if request.method == 'DELETE':
|
||||||
|
if item.owner != user:
|
||||||
|
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||||
|
item.delete()
|
||||||
|
return JsonResponse({'ok': True})
|
||||||
|
if item.owner != user:
|
||||||
|
return JsonResponse({'error': 'forbidden'}, status=403)
|
||||||
|
data = json.loads(request.body or '{}')
|
||||||
|
for field in ['content', 'comment', 'kind', 'visibility', 'is_done', 'due_at', 'url']:
|
||||||
|
if field in data:
|
||||||
|
setattr(item, field, data[field])
|
||||||
|
item.save()
|
||||||
|
item.sync_metadata()
|
||||||
|
return JsonResponse(serialize_item(item))
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'my2dos.settings')
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
SECRET_KEY = 'dev-only-change-me'
|
||||||
|
DEBUG = True
|
||||||
|
ALLOWED_HOSTS = ['*']
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'core',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'my2dos.urls'
|
||||||
|
TEMPLATES = [{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [BASE_DIR / 'templates'],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {'context_processors': [
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
]},
|
||||||
|
}]
|
||||||
|
WSGI_APPLICATION = 'my2dos.wsgi.application'
|
||||||
|
|
||||||
|
DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3'}}
|
||||||
|
|
||||||
|
AUTH_PASSWORD_VALIDATORS = []
|
||||||
|
LANGUAGE_CODE = 'de-de'
|
||||||
|
TIME_ZONE = 'Europe/Berlin'
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
STATIC_URL = 'static/'
|
||||||
|
STATICFILES_DIRS = [BASE_DIR / 'static'] if (BASE_DIR / 'static').exists() else []
|
||||||
|
MEDIA_URL = 'media/'
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
LOGIN_URL = '/login/'
|
||||||
|
LOGIN_REDIRECT_URL = '/'
|
||||||
|
LOGOUT_REDIRECT_URL = '/login/'
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import include, path
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('', include('core.urls')),
|
||||||
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import os
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'my2dos.settings')
|
||||||
|
application = get_wsgi_application()
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
Django>=5.0,<6.0
|
||||||
|
Markdown>=3.6
|
||||||
|
bleach>=6.1
|
||||||
Reference in New Issue
Block a user