Add teams and internationalization

This commit is contained in:
rucki
2026-08-23 14:55:04 +02:00
parent 218d841d7f
commit c409fbc441
26 changed files with 1582 additions and 149 deletions
+6 -3
View File
@@ -1,5 +1,5 @@
from django.contrib import admin
from .models import ApiKey, Attachment, Item, Kanban, Tag, UserPreference
from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserPreference
class AttachmentInline(admin.TabularInline):
@@ -9,8 +9,8 @@ class AttachmentInline(admin.TabularInline):
@admin.register(Item)
class ItemAdmin(admin.ModelAdmin):
list_display = ('id', 'kind', 'visibility', 'is_done', 'owner', 'created_at')
list_filter = ('kind', 'visibility', 'is_done', 'tags')
list_display = ('id', 'kind', 'visibility', 'team', 'is_done', 'owner', 'created_at')
list_filter = ('kind', 'visibility', 'team', 'is_done', 'tags')
search_fields = ('content', 'url')
inlines = [AttachmentInline]
@@ -22,5 +22,8 @@ class ApiKeyAdmin(admin.ModelAdmin):
admin.site.register(Tag)
admin.site.register(Team)
admin.site.register(TeamMembership)
admin.site.register(TeamInvite)
admin.site.register(Kanban)
admin.site.register(UserPreference)
+34 -11
View File
@@ -1,6 +1,7 @@
from django import forms
from django.contrib.auth.models import User
from .models import ApiKey, Attachment, Item, Kanban, Tag, UserPreference
from django.utils.translation import gettext_lazy as _
from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, UserPreference
class MultipleFileInput(forms.ClearableFileInput):
@@ -24,7 +25,7 @@ class QuickItemForm(forms.ModelForm):
widgets = {'content': forms.Textarea(attrs={
'class': 'form-control form-control-lg',
'rows': 3,
'placeholder': 'Schreibe etwas … /todo /link /public #tag [[note:1]]',
'placeholder': _('Write something … /todo /link /public #tag [[note:1]]'),
'x-model': 'text',
'@keydown': 'onKeydown($event)',
'@input': 'onInput($event)',
@@ -61,23 +62,45 @@ class UserPreferenceForm(forms.ModelForm):
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',
'show_notes': _('Show notes'),
'show_open_todos': _('Show open tasks'),
'show_done_todos': _('Show completed tasks'),
'show_links': _('Show links'),
'show_journal': _('Show journal'),
'overview_lines': _('Lines in overview'),
}
widgets = {'overview_lines': forms.NumberInput(attrs={'class': 'form-control', 'min': 1, 'max': 50})}
class TeamForm(forms.ModelForm):
class Meta:
model = Team
fields = ['name', 'slug']
labels = {'name': _('Team name'), 'slug': _('Team tag')}
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'my2dos'}),
'slug': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'my2dos'}),
}
def clean_slug(self):
return self.cleaned_data['slug'].strip().lstrip('#').lower()
class TeamInviteForm(forms.ModelForm):
class Meta:
model = TeamInvite
fields = ['email']
labels = {'email': _('Email')}
widgets = {'email': forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'person@example.com'})}
class KanbanForm(forms.ModelForm):
tag_name = forms.CharField(label='Tag', widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'my2dos'}))
tag_name = forms.CharField(label=_('Tag'), widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'my2dos'}))
class Meta:
model = Kanban
fields = ['name', 'tag_name']
widgets = {'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Name des Kanbans'})}
widgets = {'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('Kanban name')})}
def save(self, commit=True):
tag_name = self.cleaned_data['tag_name'].strip().lstrip('#').lower()
@@ -91,7 +114,7 @@ class ApiKeyForm(forms.ModelForm):
model = ApiKey
fields = ['name', 'tags']
widgets = {
'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Name des API Keys'}),
'name': forms.TextInput(attrs={'class': 'form-control', 'placeholder': _('API key name')}),
'tags': forms.CheckboxSelectMultiple(),
}
@@ -0,0 +1,67 @@
# Generated by Django 5.2.17 on 2026-08-23 09:28
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0009_item_kanban_status'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='item',
name='visibility',
field=models.CharField(choices=[('private', 'Privat'), ('team', 'Team'), ('public', 'Öffentlich')], default='private', max_length=10),
),
migrations.CreateModel(
name='Team',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=120)),
('slug', models.SlugField(allow_unicode=True, help_text='Team-Tag ohne #, z.B. my2dos', max_length=80, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_teams', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['name'],
},
),
migrations.AddField(
model_name='item',
name='team',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='items', to='core.team'),
),
migrations.CreateModel(
name='TeamInvite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(blank=True, max_length=254)),
('token', models.CharField(editable=False, max_length=80, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('accepted_at', models.DateTimeField(blank=True, null=True)),
('invited_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_team_invites', to=settings.AUTH_USER_MODEL)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='core.team')),
],
options={
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='TeamMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('owner', 'Owner'), ('admin', 'Admin'), ('member', 'Mitglied')], default='member', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='core.team')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='team_memberships', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('team', 'user')},
},
),
]
@@ -0,0 +1,28 @@
# Generated by Django 5.2.17 on 2026-08-23 12:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0010_alter_item_visibility_team_item_team_teaminvite_and_more'),
]
operations = [
migrations.AlterField(
model_name='item',
name='kanban_status',
field=models.CharField(choices=[('inbox', 'Inbox'), ('todo', 'Todo'), ('progress', 'In Progress'), ('done', 'Done')], default='inbox', max_length=20),
),
migrations.AlterField(
model_name='item',
name='kind',
field=models.CharField(choices=[('todo', 'Task'), ('note', 'Note'), ('link', 'Link'), ('journal', 'Journal')], default='note', max_length=10),
),
migrations.AlterField(
model_name='item',
name='visibility',
field=models.CharField(choices=[('private', 'Private'), ('team', 'Team'), ('public', 'Public')], default='private', max_length=10),
),
]
+63 -9
View File
@@ -3,6 +3,7 @@ import secrets
from django.conf import settings
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
TAG_RE = re.compile(r'(?<!\w)#([\wäöüÄÖÜß-]+)')
LINK_RE = re.compile(r'\[\[(todo|note|link|journal)?:?(\d+)\]\]', re.I)
@@ -18,27 +19,80 @@ class Tag(models.Model):
return f'#{self.name}'
class Team(models.Model):
name = models.CharField(max_length=120)
slug = models.SlugField(max_length=80, unique=True, allow_unicode=True, help_text='Team-Tag ohne #, z.B. my2dos')
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='owned_teams')
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ['name']
def __str__(self):
return self.name
class TeamMembership(models.Model):
class Role(models.TextChoices):
OWNER = 'owner', 'Owner'
ADMIN = 'admin', 'Admin'
MEMBER = 'member', 'Mitglied'
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='memberships')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='team_memberships')
role = models.CharField(max_length=10, choices=Role.choices, default=Role.MEMBER)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = [('team', 'user')]
def __str__(self):
return f'{self.user} in {self.team}'
class TeamInvite(models.Model):
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='invites')
email = models.EmailField(blank=True)
token = models.CharField(max_length=80, unique=True, editable=False)
invited_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='sent_team_invites')
created_at = models.DateTimeField(auto_now_add=True)
accepted_at = models.DateTimeField(blank=True, null=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 f'Einladung {self.team} {self.email}'
class Item(models.Model):
class Kind(models.TextChoices):
TODO = 'todo', 'Aufgabe'
NOTE = 'note', 'Notiz'
LINK = 'link', 'Link'
JOURNAL = 'journal', 'Journal'
TODO = 'todo', _('Task')
NOTE = 'note', _('Note')
LINK = 'link', _('Link')
JOURNAL = 'journal', _('Journal')
class Visibility(models.TextChoices):
PRIVATE = 'private', 'Privat'
PUBLIC = 'public', 'Öffentlich'
PRIVATE = 'private', _('Private')
TEAM = 'team', _('Team')
PUBLIC = 'public', _('Public')
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='items')
team = models.ForeignKey(Team, on_delete=models.SET_NULL, blank=True, null=True, 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)
kanban_status = models.CharField(max_length=20, default='inbox', choices=[
('inbox', 'Eingang'),
('inbox', _('Inbox')),
('todo', 'Todo'),
('progress', 'In Progress'),
('done', 'Erledigt'),
('progress', _('In Progress')),
('done', _('Done')),
])
is_done = models.BooleanField(default=False)
completed_at = models.DateTimeField(blank=True, null=True)
+12 -11
View File
@@ -1,33 +1,34 @@
{% load i18n %}
<form id="item-{{ item.id }}" class="card item-card shadow-sm position-relative" method="post" enctype="multipart/form-data" hx-post="{% url 'edit_item' item.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-target="#item-{{ item.id }}" hx-swap="outerHTML" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}], {{ item.id }})" x-init="text=$el.querySelector('textarea').value">
{% csrf_token %}
<div class="card-body vstack gap-2">
<div class="small text-muted">Typ: {{ item.get_kind_display }}</div>
<div class="small text-muted">{% trans "Type:" %} {{ item.get_kind_display }}</div>
<div class="mobile-tag-picker">
<button type="button" class="btn btn-sm btn-outline-secondary" @click="showTags=true">Tags hinzufügen</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @click="showTags=true">{% trans "Add tags" %}</button>
<div class="mobile-tag-backdrop" x-show="showTags" @click="showTags=false" x-cloak></div>
<div class="mobile-tag-popup card shadow" x-show="showTags" x-cloak>
<div class="card-body"><div class="d-flex justify-content-between align-items-center mb-2"><strong>Tag auswählen</strong><button type="button" class="btn-close" @click="showTags=false"></button></div><div class="d-flex flex-wrap gap-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></div>
<div class="card-body"><div class="d-flex justify-content-between align-items-center mb-2"><strong>{% trans "Choose tag" %}</strong><button type="button" class="btn-close" @click="showTags=false"></button></div><div class="d-flex flex-wrap gap-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></div>
</div>
</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>
<textarea class="form-control" name="comment" rows="3" placeholder="{% trans 'Comment, e.g. same task already captured [[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>
<option value="private" {% if item.visibility == 'private' %}selected{% endif %}>{% trans "Private" %}</option>
<option value="public" {% if item.visibility == 'public' %}selected{% endif %}>{% trans "Public" %}</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 %}
{% 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="{% trans 'Date/time for todo' %}"></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">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">{% trans "Image from clipboard:" %}</span><br><img :src="preview" class="img-thumbnail mt-1" style="max-height:160px"></div></template>
<div class="d-flex gap-2">
<button class="btn btn-dark btn-sm">Speichern</button>
<a class="btn btn-outline-primary btn-sm" href="{% url 'item_editor' item.id %}?next={{ request.get_full_path|urlencode }}">Im Editor öffnen</a>
<button type="button" class="btn btn-outline-secondary btn-sm" hx-get="{% url 'item_card' item.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-target="#item-{{ item.id }}" hx-swap="outerHTML">Abbrechen</button>
<button class="btn btn-dark btn-sm">{% trans "Save" %}</button>
<a class="btn btn-outline-primary btn-sm" href="{% url 'item_editor' item.id %}?next={{ request.get_full_path|urlencode }}">{% trans "Open in editor" %}</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">{% trans "Cancel" %}</button>
</div>
</div>
</form>
+6 -6
View File
@@ -1,24 +1,24 @@
{% load markdown_extras %}
{% load markdown_extras i18n %}
<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="item-row d-flex justify-content-between align-items-start gap-3">
<div class="item-main flex-grow-1 min-w-0" style="min-width:0">
<div class="mb-2 d-flex flex-wrap gap-2 align-items-center">
<span class="badge {% if item.kind == 'todo' %}text-bg-primary{% elif item.kind == 'link' %}text-bg-success{% 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>
{% if item.team %}<a class="badge text-bg-warning text-decoration-none" href="{% url 'team_detail' item.team.id %}">{{ item.team.name }}</a>{% else %}<span class="badge text-bg-light">{{ item.get_visibility_display }}</span>{% endif %}
<a class="text-muted small" href="{{ item.get_absolute_url }}?next={{ request.get_full_path|urlencode }}">#{{ item.id }}</a>
{% for tag in item.tags.all %}<a class="badge rounded-pill text-bg-light text-decoration-none tag" href="/?tag={{ tag.name }}">#{{ tag.name }}</a>{% endfor %}
</div>
<div 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 %}
{% 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 ? '{% trans "show less" %}' : '{% trans "show more" %}'"></button>{% endif %}
</div>
{% if item.comment %}<div class="border-start ps-2 mt-2 small content text-muted">{{ item.comment|markdown }}</div>{% endif %}
{% if item.due_at %}<div class="small text-warning-emphasis mt-2">Fällig: {{ item.due_at|date:'d.m.Y H:i' }}</div>{% endif %}
{% if item.due_at %}<div class="small text-warning-emphasis mt-2">{% trans "Due:" %} {{ 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.linked_items.all %}<div class="small mt-2">{% trans "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="small mt-2">{% trans "Attachments:" %} {% 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 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>
+2 -2
View File
@@ -1,4 +1,4 @@
{% load querystring %}
{% load querystring i18n %}
<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 %}
@@ -6,5 +6,5 @@
{% 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 %}
{% empty %}<span class="text-muted small">{% trans "No tags yet" %}</span>{% endfor %}
</div>
+8 -7
View File
@@ -1,4 +1,4 @@
{% load static %}
{% load static i18n %}
<!doctype html>
<html lang="de">
<head>
@@ -21,7 +21,7 @@
</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"><a class="small d-none d-sm-inline text-primary text-decoration-underline" href="/">Notizen · Aufgaben · Links</a>{% if user.is_authenticated %}<a class="small" href="{% url 'journal' %}">Journal</a><a class="small" href="{% url 'kanban_list' %}">Kanban</a><div class="dropdown"><button class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown">{{ user.get_full_name|default:user.username }}</button><ul class="dropdown-menu dropdown-menu-end"><li><a class="dropdown-item" href="{% url 'settings' %}">Einstellungen</a></li><li><button id="pwa-install" type="button" class="dropdown-item d-none">App installieren</button></li>{% if user.is_staff %}<li><a class="dropdown-item" href="/admin/">Admin</a></li>{% endif %}<li><hr class="dropdown-divider"></li><li><form method="post" action="{% url 'logout' %}">{% csrf_token %}<button class="dropdown-item">Logout</button></form></li></ul></div>{% 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"><a class="small d-none d-sm-inline text-primary text-decoration-underline" href="/">{% trans "Notes · Tasks · Links" %}</a>{% if user.is_authenticated %}<a class="small" href="{% url 'journal' %}">{% trans "Journal" %}</a><a class="small" href="{% url 'kanban_list' %}">{% trans "Kanban" %}</a><a class="small" href="{% url 'settings' %}#teams">{% trans "Teams" %}</a><div class="dropdown"><button class="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown">{{ user.get_full_name|default:user.username }}</button><ul class="dropdown-menu dropdown-menu-end"><li><a class="dropdown-item" href="{% url 'settings' %}">{% trans "Settings" %}</a></li><li><button id="pwa-install" type="button" class="dropdown-item d-none">{% trans "Install app" %}</button></li>{% if user.is_staff %}<li><a class="dropdown-item" href="/admin/">Admin</a></li>{% endif %}<li><hr class="dropdown-divider"></li><li><form method="post" action="{% url 'logout' %}">{% csrf_token %}<button class="dropdown-item">{% trans "Logout" %}</button></form></li></ul></div>{% else %}<a class="btn btn-sm btn-dark" href="{% url 'login' %}">{% trans "Login" %}</a>{% endif %}<form action="{% url 'set_language' %}" method="post" class="d-flex align-items-center">{% csrf_token %}<input name="next" type="hidden" value="{{ request.get_full_path }}"><select name="language" class="form-select form-select-sm" onchange="this.form.submit()">{% get_current_language as LANGUAGE_CODE %}{% get_available_languages as LANGUAGES %}{% for code, name in LANGUAGES %}<option value="{{ code }}"{% if code == LANGUAGE_CODE %} selected{% endif %}>{{ name }}</option>{% endfor %}</select></form></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 }}'});
@@ -29,16 +29,17 @@ window.quickComposer = window.quickComposer || function(tags, itemId=null){
return {
open:false,showTags:false,text:'',active:0,query:'',currentEl:null,preview:null,pendingFiles:[],linkSuggestions:[],attachmentSuggestions:[],itemId:itemId,tags:tags||[],
init(){document.body.addEventListener('tagsChanged',()=>{fetch('/api/tags/').then(r=>r.json()).then(d=>{this.tags=d.tags||[]})})},
commands:[{token:'/todo',label:'Aufgabe erstellen'},{token:'/link',label:'Link speichern'},{token:'/journal',label:'Journal-Eintrag'},{token:'/public',label:'öffentlich'},{token:'/private',label:'privat'},{token:'/code',label:'Markdown Codeblock einfügen'},{token:'[[',label:'interne Aufgabe/Notiz suchen'},{token:'![[',label:'Bild aus Anhängen einfügen'}],
get filtered(){let q=this.query.toLowerCase();let list=q.startsWith('#')?this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q)).map(t=>({token:'#'+t,label:'Tag'})):q.startsWith('![[')?(this.itemId?this.attachmentSuggestions.map(a=>({token:`![[file:${a.id}]]`,label:`Bild: ${a.name}`})):this.pendingFiles.map(f=>({token:`![[paste:${f.name}]]`,label:`Bild: ${f.name}`}))):q.startsWith('[[')?this.linkSuggestions.map(i=>({token:`[[${i.id}]]`,label:`${i.kind}: ${i.label}`})):this.commands.filter(c=>!q||c.token.toLowerCase().startsWith(q));return list.length?list:[{token:this.query,label:'neu'}]},
commands:[{token:'/todo',label:'{% trans "Create task" %}'},{token:'/link',label:'{% trans "Save link" %}'},{token:'/journal',label:'{% trans "Journal entry" %}'},{token:'/public',label:'{% trans "public" %}'},{token:'/private',label:'{% trans "private" %}'},{token:'/code',label:'{% trans "Insert Markdown code block" %}'},{token:'[[',label:'{% trans "Search internal task/note" %}'},{token:'![[',label:'{% trans "Insert image from attachments" %}'}],
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:`{% trans "Image:" %} ${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'}]},
isContentField(el){return el && (el.name==='content' || el.id==='id_content')},
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();[...(this.$refs.fileInput.files||[])].forEach(f=>dt.items.add(f));dt.items.add(named);this.$refs.fileInput.files=dt.files;this.pendingFiles=[...dt.files].filter(f=>f.type.startsWith('image/'));this.preview=URL.createObjectURL(named)},
insertImageToken(){if(this.pendingFiles.length)this.insert(`![[paste:${this.pendingFiles[this.pendingFiles.length-1].name}]]`)},
previewFile(e){this.pendingFiles=[...e.target.files].filter(f=>f.type.startsWith('image/'));let file=this.pendingFiles[0];this.preview=file?URL.createObjectURL(file):null},
onKeydown(e){this.currentEl=e.target;if((e.ctrlKey||e.metaKey)&&e.key==='Enter'){e.preventDefault();this.open=false;e.target.form.requestSubmit();return}if(this.open&&e.key==='ArrowDown'){e.preventDefault();this.active=(this.active+1)%this.filtered.length;return}if(this.open&&e.key==='ArrowUp'){e.preventDefault();this.active=(this.active-1+this.filtered.length)%this.filtered.length;return}if(this.open&&(e.key==='Enter'||e.key==='Tab')&&!e.shiftKey){e.preventDefault();this.insert(this.filtered[this.active].token);return}if(e.key==='Escape'){this.open=false;return}if(e.key===' '){this.open=false;return}setTimeout(()=>this.update(e.target),0)},
update(el){this.currentEl=el;this.text=el.value;let before=el.value.slice(0,el.selectionStart);let fragment=(before.match(/(^|\s)([^\s]*)$/)||['','',''])[2];this.query=fragment;this.active=0;this.open=fragment.startsWith('/')||fragment.startsWith('#')||fragment.startsWith('[[')||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||[]})}},
addToken(token){let el=this.currentEl||this.$el.querySelector('textarea');if(!el)return;let pos=el.selectionStart??el.value.length;let before=el.value.slice(0,pos);let after=el.value.slice(pos);let prefix=before&& !before.endsWith(' ') && !before.endsWith('\n')?' ':'';let value=before+prefix+token+' '+after;this.text=value;el.value=value;this.showTags=false;this.$nextTick(()=>{let p=before.length+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)})}
update(el){this.currentEl=el;if(this.isContentField(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||[]})}},
addToken(token){let el=this.currentEl||this.$el.querySelector('textarea');if(!el)return;let pos=el.selectionStart??el.value.length;let before=el.value.slice(0,pos);let after=el.value.slice(pos);let prefix=before&& !before.endsWith(' ') && !before.endsWith('\n')?' ':'';let value=before+prefix+token+' '+after;if(this.isContentField(el))this.text=value;el.value=value;this.showTags=false;this.$nextTick(()=>{let p=before.length+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;if(this.isContentField(el))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()}}}
+2 -1
View File
@@ -1,6 +1,7 @@
{% extends 'core/base.html' %}
{% load i18n %}
{% block content %}
<a class="btn btn-sm btn-outline-secondary mb-3" href="{{ request.GET.next|default:'/' }}">← zurück</a>
<a class="btn btn-sm btn-outline-secondary mb-3" href="{{ request.GET.next|default:'/' }}">← {% trans "back" %}</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>
+33 -32
View File
@@ -1,47 +1,47 @@
{% extends 'core/base.html' %}
{% load querystring %}
{% load querystring i18n %}
{% block content %}
<div class="mobile-create-nav gap-2 mb-3">
<a class="btn btn-sm btn-primary flex-fill" href="{% url 'new_item' %}?kind=todo&next={{ request.get_full_path|urlencode }}">Aufgabe</a>
<a class="btn btn-sm btn-secondary flex-fill" href="{% url 'new_item' %}?kind=note&next={{ request.get_full_path|urlencode }}">Notiz</a>
<a class="btn btn-sm btn-primary flex-fill" href="{% url 'new_item' %}?kind=todo&next={{ request.get_full_path|urlencode }}">{% trans "Task" %}</a>
<a class="btn btn-sm btn-secondary flex-fill" href="{% url 'new_item' %}?kind=note&next={{ request.get_full_path|urlencode }}">{% trans "Note" %}</a>
<a class="btn btn-sm btn-success flex-fill" href="{% url 'new_item' %}?kind=link&next={{ request.get_full_path|urlencode }}">Link</a>
<a class="btn btn-sm btn-info flex-fill" href="{% url 'new_item' %}?kind=journal&next={{ request.get_full_path|urlencode }}">Journal</a>
</div>
<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="fw-semibold mb-2">{% trans "Filter" %}</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<a class="btn btn-sm {% if active_kind == 'todo' %}btn-primary{% else %}btn-outline-primary{% endif %}" href="{% if active_kind == 'todo' %}{% query_replace kind=None %}{% else %}{% query_replace kind='todo' %}{% endif %}">Todos{% if active_kind == 'todo' %} ×{% endif %}</a>
<a class="btn btn-sm {% if active_kind == 'note' %}btn-secondary{% else %}btn-outline-secondary{% endif %}" href="{% if active_kind == 'note' %}{% query_replace kind=None %}{% else %}{% query_replace kind='note' %}{% endif %}">Notizen{% if active_kind == 'note' %} ×{% endif %}</a>
<a class="btn btn-sm {% if active_kind == 'note' %}btn-secondary{% else %}btn-outline-secondary{% endif %}" href="{% if active_kind == 'note' %}{% query_replace kind=None %}{% else %}{% query_replace kind='note' %}{% endif %}">{% trans "Notes" %}{% if active_kind == 'note' %} ×{% endif %}</a>
<a class="btn btn-sm {% if active_kind == 'link' %}btn-success{% else %}btn-outline-success{% endif %}" href="{% if active_kind == 'link' %}{% query_replace kind=None %}{% else %}{% query_replace kind='link' %}{% endif %}">Links{% if active_kind == 'link' %} ×{% endif %}</a>
<a class="btn btn-sm {% if active_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 == 'open' %}btn-warning{% else %}btn-outline-warning{% endif %}" href="{% if active_status == 'open' %}{% query_replace status=None %}{% else %}{% query_replace status='open' %}{% endif %}">{% trans "Open" %}{% 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 %}">{% trans "Archive" %}{% 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 %}
<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 %}
<input x-ref="search" class="form-control" name="q" value="{{ q }}" placeholder="{% trans 'Search (Ctrl+F, # for 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="{% trans 'Clear search' %}">×</a>{% endif %}
</div>
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
<template x-for="(tag, idx) in filtered" :key="tag"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="select(tag)"><strong x-text="'#'+tag"></strong></button></template>
</div>
</form>
<div class="d-none d-md-block">
<div class="fw-semibold mb-2">Tags</div>
<div class="fw-semibold mb-2">{% trans "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>
<details class="d-md-none">
<summary class="btn btn-sm btn-outline-secondary mb-2">Tags anzeigen</summary>
<summary class="btn btn-sm btn-outline-secondary mb-2">{% trans "Show tags" %}</summary>
<div class="d-flex flex-wrap gap-2">
{% 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 %}
{% empty %}<span class="text-muted small">{% trans "No tags yet" %}</span>{% endfor %}
</div>
</details>
</div></div>
@@ -50,26 +50,26 @@
<form class="desktop-quick-create 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 %}
<div class="mobile-create-buttons gap-2 mb-2">
<button type="button" class="btn btn-sm btn-primary flex-fill" @click="insert('/todo')">Aufgabe</button>
<button type="button" class="btn btn-sm btn-secondary flex-fill" @click="$el.querySelector('textarea').focus()">Notiz</button>
<button type="button" class="btn btn-sm btn-primary flex-fill" @click="insert('/todo')">{% trans "Task" %}</button>
<button type="button" class="btn btn-sm btn-secondary flex-fill" @click="$el.querySelector('textarea').focus()">{% trans "Note" %}</button>
<button type="button" class="btn btn-sm btn-success flex-fill" @click="insert('/link')">Link</button>
<button type="button" class="btn btn-sm btn-info flex-fill" @click="insert('/journal')">Journal</button>
</div>
<details class="mobile-tag-picker mb-2"><summary class="btn btn-sm btn-outline-secondary">Tags hinzufügen</summary><div class="mobile-tags flex-wrap gap-2 mt-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></details>
<details class="mobile-tag-picker mb-2"><summary class="btn btn-sm btn-outline-secondary">{% trans "Add tags" %}</summary><div class="mobile-tags flex-wrap gap-2 mt-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></details>
{{ 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 quick-actions">
<input class="form-control desktop-file" type="file" name="files" multiple accept="image/*,.pdf,.txt,.md" x-ref="fileInput" @change="previewFile($event)">
<div class="mobile-media gap-2 w-100"><label class="btn btn-outline-secondary flex-fill mb-0">Galerie<input class="d-none" type="file" name="files" multiple accept="image/*" @change="previewFile($event)"></label><label class="btn btn-outline-secondary flex-fill mb-0">Foto<input class="d-none" type="file" name="files" accept="image/*" capture="environment" @change="previewFile($event)"></label></div>
<a class="btn btn-outline-secondary" href="{% url 'new_item' %}">Editor</a>
<button class="btn btn-dark px-4">Speichern</button>
<div class="mobile-media gap-2 w-100"><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Gallery" %}<input class="d-none" type="file" name="files" multiple accept="image/*" @change="previewFile($event)"></label><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Photo" %}<input class="d-none" type="file" name="files" accept="image/*" capture="environment" @change="previewFile($event)"></label></div>
<a class="btn btn-outline-secondary" href="{% url 'new_item' %}">{% trans "Editor" %}</a>
<button class="btn btn-dark px-4">{% trans "Save" %}</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>
<template x-if="preview"><div class="mt-2 small"><span class="text-muted">{% trans "Image from clipboard:" %}</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 %}
{% for item in items %}{% include 'core/_item.html' %}{% empty %}<div class="text-center text-muted py-5">{% trans "Nothing here yet." %}</div>{% endfor %}
</div>
</section>
</div>
@@ -96,26 +96,27 @@ function quickComposer(tags, itemId=null){
});
},
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'}
{token:'/todo',label:'{% trans "Create task" %}'},
{token:'/link',label:'{% trans "Save link" %}'},
{token:'/journal',label:'{% trans "Journal entry" %}'},
{token:'/public',label:'{% trans "public" %}'},
{token:'/private',label:'{% trans "private" %}'},
{token:'/code',label:'{% trans "Insert Markdown code block" %}'},
{token:'[[',label:'{% trans "Search internal task/note" %}'},
{token:'![[',label:'{% trans "Insert image from attachments" %}'}
],
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}`})))
? (this.itemId?this.attachmentSuggestions.map(a=>({token:`![[file:${a.id}]]`,label:`{% trans "Image:" %} ${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'}];
},
isContentField(el){return el && (el.name==='content' || el.id==='id_content')},
onInput(e){this.update(e.target)},
handlePaste(e){
let item=[...(e.clipboardData?.items||[])].find(i=>i.kind==='file'&&i.type.startsWith('image/'));
@@ -170,7 +171,7 @@ function quickComposer(tags, itemId=null){
},
update(el){
this.currentEl=el;
this.text=el.value;
if(this.isContentField(el)) this.text=el.value;
let before=el.value.slice(0,el.selectionStart);
let fragment=(before.match(/(^|\s)([^\s]*)$/)||['','',''])[2];
this.query=fragment;
@@ -191,7 +192,7 @@ function quickComposer(tags, itemId=null){
let after=el.value.slice(pos);
let prefix=before&&!before.endsWith(' ')&&!before.endsWith('\n')?' ':'';
let value=before+prefix+token+' '+after;
this.text=value;
if(this.isContentField(el)) this.text=value;
el.value=value;
this.showTags=false;
this.$nextTick(()=>{let p=before.length+prefix.length+token.length+1;el.focus();el.setSelectionRange(p,p)})
@@ -210,7 +211,7 @@ function quickComposer(tags, itemId=null){
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;
if(this.isContentField(el)) this.text=value;
el.value=value;
this.open=false;
this.$nextTick(()=>{
+10 -9
View File
@@ -1,33 +1,34 @@
{% extends 'core/base.html' %}
{% load i18n %}
{% block content %}
<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">
<div class="d-flex justify-content-between align-items-center gap-2 sticky-top bg-white py-2" style="z-index:5">
<h1 class="h5 m-0">Eintrag #{{ item.id }} bearbeiten</h1>
<div class="d-flex gap-2"><button class="btn btn-success px-3 py-2" title="Speichern">✓</button><a class="btn btn-danger px-3 py-2" href="{{ next_url }}" title="Abbrechen">×</a></div>
<h1 class="h5 m-0">{% blocktrans with id=item.id %}Edit item #{{ id }}{% endblocktrans %}</h1>
<div class="d-flex gap-2"><button class="btn btn-success px-3 py-2" title="{% trans 'Save' %}">✓</button><a class="btn btn-danger px-3 py-2" href="{{ next_url }}" title="{% trans 'Cancel' %}">×</a></div>
</div>
<div class="small text-muted">Typ: {{ item.get_kind_display }}</div>
<div class="small text-muted">{% trans "Type:" %} {{ item.get_kind_display }}</div>
<div class="mobile-tag-picker">
<button type="button" class="btn btn-sm btn-outline-secondary" @click="showTags=true">Tags hinzufügen</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @click="showTags=true">{% trans "Add tags" %}</button>
<div class="mobile-tag-backdrop" x-show="showTags" @click="showTags=false" x-cloak></div>
<div class="mobile-tag-popup card shadow" x-show="showTags" x-cloak>
<div class="card-body"><div class="d-flex justify-content-between align-items-center mb-2"><strong>Tag auswählen</strong><button type="button" class="btn-close" @click="showTags=false"></button></div><div class="d-flex flex-wrap gap-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></div>
<div class="card-body"><div class="d-flex justify-content-between align-items-center mb-2"><strong>{% trans "Choose tag" %}</strong><button type="button" class="btn-close" @click="showTags=false"></button></div><div class="d-flex flex-wrap gap-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></div>
</div>
</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>
<textarea class="form-control" name="comment" rows="5" placeholder="{% trans 'Comment' %}" @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>
<div class="col-md-4"><select class="form-select" name="visibility"><option value="private" {% if item.visibility == 'private' %}selected{% endif %}>{% trans "Private" %}</option><option value="public" {% if item.visibility == 'public' %}selected{% endif %}>{% trans "Public" %}</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 d-none d-md-flex"><button class="btn btn-dark">Speichern</button><a class="btn btn-outline-secondary" href="{{ next_url }}">Abbrechen</a></div>
<template x-if="preview"><div class="small"><span class="text-muted">{% trans "Image preview:" %}</span><br><img :src="preview" class="img-thumbnail mt-1" style="max-height:180px"></div></template>
<div class="d-flex gap-2 d-none d-md-flex"><button class="btn btn-dark">{% trans "Save" %}</button><a class="btn btn-outline-secondary" href="{{ next_url }}">{% trans "Cancel" %}</a></div>
</div>
</form>
{% endblock %}
+7 -7
View File
@@ -1,5 +1,5 @@
{% extends 'core/base.html' %}
{% load querystring %}
{% load querystring i18n %}
{% block content %}
<div class="row g-4">
<aside class="col-lg-3 order-lg-2">
@@ -12,7 +12,7 @@
<table class="table table-sm text-center mb-3"><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 d in week %}<td>{% if d.day %}{% if d.has_entries %}<a class="btn btn-sm {% if selected_day == d.date %}btn-info{% else %}btn-outline-info{% endif %}" href="?day={{ d.date }}&year={{ year }}&month={{ month }}">{{ d.day }}</a>{% else %}<span class="small text-muted">{{ d.day }}</span>{% endif %}{% endif %}</td>{% endfor %}</tr>{% endfor %}
</tbody></table>
<div class="fw-semibold mb-2">Tags</div>
<div class="fw-semibold mb-2">{% trans "Tags" %}</div>
{% include 'core/_tags.html' %}
</div></div>
</aside>
@@ -22,17 +22,17 @@
{% csrf_token %}
<div class="mobile-create-buttons gap-2 mb-2">
<button type="button" class="btn btn-sm btn-info flex-fill" @click="insert('/journal')">Journal</button>
<button type="button" class="btn btn-sm btn-primary flex-fill" @click="insert('/todo')">Aufgabe</button>
<button type="button" class="btn btn-sm btn-secondary flex-fill" @click="$el.querySelector('textarea').focus()">Notiz</button>
<button type="button" class="btn btn-sm btn-primary flex-fill" @click="insert('/todo')">{% trans "Task" %}</button>
<button type="button" class="btn btn-sm btn-secondary flex-fill" @click="$el.querySelector('textarea').focus()">{% trans "Note" %}</button>
<button type="button" class="btn btn-sm btn-success flex-fill" @click="insert('/link')">Link</button>
</div>
<details class="mobile-tag-picker mb-2"><summary class="btn btn-sm btn-outline-secondary">Tags hinzufügen</summary><div class="mobile-tags flex-wrap gap-2 mt-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></details>
<details class="mobile-tag-picker mb-2"><summary class="btn btn-sm btn-outline-secondary">{% trans "Add tags" %}</summary><div class="mobile-tags flex-wrap gap-2 mt-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></details>
{{ 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 quick-actions"><input class="form-control desktop-file" type="file" name="files" multiple accept="image/*,.pdf,.txt,.md" x-ref="fileInput" @change="previewFile($event)"><div class="mobile-media gap-2 w-100"><label class="btn btn-outline-secondary flex-fill mb-0">Galerie<input class="d-none" type="file" name="files" multiple accept="image/*" @change="previewFile($event)"></label><label class="btn btn-outline-secondary flex-fill mb-0">Foto<input class="d-none" type="file" name="files" accept="image/*" capture="environment" @change="previewFile($event)"></label></div><button class="btn btn-dark px-4">Speichern</button></div>
<div class="d-flex align-items-center gap-2 mt-2 quick-actions"><input class="form-control desktop-file" type="file" name="files" multiple accept="image/*,.pdf,.txt,.md" x-ref="fileInput" @change="previewFile($event)"><div class="mobile-media gap-2 w-100"><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Gallery" %}<input class="d-none" type="file" name="files" multiple accept="image/*" @change="previewFile($event)"></label><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Photo" %}<input class="d-none" type="file" name="files" accept="image/*" capture="environment" @change="previewFile($event)"></label></div><button class="btn btn-dark px-4">{% trans "Save" %}</button></div>
</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">Keine Journal-Aktivität an diesem Tag.</div>{% endfor %}
{% for item in items %}{% include 'core/_item.html' %}{% empty %}<div class="text-center text-muted py-5">{% trans "No journal activity on this day." %}</div>{% endfor %}
</div>
</section>
</div>
+3 -2
View File
@@ -1,12 +1,13 @@
{% extends 'core/base.html' %}
{% load i18n %}
{% block content %}
<a class="btn btn-sm btn-outline-secondary mb-3" href="{% url 'kanban_list' %}">← Kanbans</a>
<h1 class="h4 mb-3">{{ kanban.name }} <span class="badge text-bg-light">#{{ kanban.tag.name }}</span></h1>
<div class="row g-3" x-data="kanbanBoard({{ kanban.id }})">
<section class="col-lg-3 col-md-6"><div class="card item-card shadow-sm h-100"><div class="card-body"><h2 class="h6">Eingang</h2><div class="kanban-column vstack gap-2 p-2 rounded bg-light" data-status="inbox" @dragover.prevent @drop="drop($event)">{% for item in inbox_items %}{% include 'core/_kanban_card.html' %}{% empty %}<div class="text-muted small">Leer.</div>{% endfor %}</div></div></div></section>
<section class="col-lg-3 col-md-6"><div class="card item-card shadow-sm h-100"><div class="card-body"><h2 class="h6">{% trans "Inbox" %}</h2><div class="kanban-column vstack gap-2 p-2 rounded bg-light" data-status="inbox" @dragover.prevent @drop="drop($event)">{% for item in inbox_items %}{% include 'core/_kanban_card.html' %}{% empty %}<div class="text-muted small">Leer.</div>{% endfor %}</div></div></div></section>
<section class="col-lg-3 col-md-6"><div class="card item-card shadow-sm h-100"><div class="card-body"><h2 class="h6">Todo</h2><div class="kanban-column vstack gap-2 p-2 rounded bg-light" data-status="todo" @dragover.prevent @drop="drop($event)">{% for item in todo_items %}{% include 'core/_kanban_card.html' %}{% empty %}<div class="text-muted small">Leer.</div>{% endfor %}</div></div></div></section>
<section class="col-lg-3 col-md-6"><div class="card item-card shadow-sm h-100"><div class="card-body"><h2 class="h6">In Progress</h2><div class="kanban-column vstack gap-2 p-2 rounded bg-light" data-status="progress" @dragover.prevent @drop="drop($event)">{% for item in progress_items %}{% include 'core/_kanban_card.html' %}{% empty %}<div class="text-muted small">Leer.</div>{% endfor %}</div></div></div></section>
<section class="col-lg-3 col-md-6"><div class="card item-card shadow-sm h-100"><div class="card-body"><h2 class="h6">Erledigt</h2><div class="kanban-column vstack gap-2 p-2 rounded bg-light" data-status="done" @dragover.prevent @drop="drop($event)">{% for item in done_items %}{% include 'core/_kanban_card.html' %}{% empty %}<div class="text-muted small">Leer.</div>{% endfor %}</div></div></div></section>
<section class="col-lg-3 col-md-6"><div class="card item-card shadow-sm h-100"><div class="card-body"><h2 class="h6">{% trans "Done" %}</h2><div class="kanban-column vstack gap-2 p-2 rounded bg-light" data-status="done" @dragover.prevent @drop="drop($event)">{% for item in done_items %}{% include 'core/_kanban_card.html' %}{% empty %}<div class="text-muted small">Leer.</div>{% endfor %}</div></div></div></section>
</div>
<script>
function kanbanBoard(id){return{item:null,drag(e){this.item=e.target.closest('[data-id]')},drop(e){let col=e.target.closest('[data-status]');if(!this.item||!col)return;col.querySelectorAll('.text-muted.small').forEach(e=>e.remove());col.appendChild(this.item);this.item.classList.toggle('done', col.dataset.status==='done');fetch(`/kanban/${id}/move/${this.item.dataset.id}/`,{method:'POST',headers:{'X-CSRFToken':'{{ csrf_token }}','Content-Type':'application/x-www-form-urlencoded'},body:`status=${col.dataset.status}`});}}}
+4 -3
View File
@@ -1,8 +1,9 @@
{% extends 'core/base.html' %}
{% load i18n %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3"><h1 class="h4 m-0">Kanbans</h1><a class="btn btn-sm btn-outline-secondary" href="/">← zurück</a></div>
<div class="d-flex justify-content-between align-items-center mb-3"><h1 class="h4 m-0">Kanbans</h1><a class="btn btn-sm btn-outline-secondary" href="/">← {% trans "back" %}</a></div>
<div class="row g-4">
<section class="col-lg-5"><div class="card item-card shadow-sm"><div class="card-body"><h2 class="h5">Neues Kanban</h2><form method="post" class="vstack gap-2">{% csrf_token %}{{ form.name }}{{ form.tag_name }}<button class="btn btn-dark">Anlegen</button></form></div></div></section>
<section class="col-lg-7"><div class="vstack gap-3">{% for s in stats %}<a class="card item-card shadow-sm text-decoration-none text-dark" href="{% url 'kanban_detail' s.kanban.id %}"><div class="card-body"><div class="d-flex justify-content-between"><strong>{{ s.kanban.name }}</strong><span class="badge text-bg-light">#{{ s.kanban.tag.name }}</span></div><div class="small text-muted mt-2">Offen: {{ s.open }} · Erledigt: {{ s.done }}</div></div></a>{% empty %}<div class="text-muted">Noch keine Kanbans.</div>{% endfor %}</div></section>
<section class="col-lg-5"><div class="card item-card shadow-sm"><div class="card-body"><h2 class="h5">{% trans "New Kanban" %}</h2><form method="post" class="vstack gap-2">{% csrf_token %}{{ form.name }}{{ form.tag_name }}<button class="btn btn-dark">{% trans "Create" %}</button></form></div></div></section>
<section class="col-lg-7"><div class="vstack gap-3">{% for s in stats %}<a class="card item-card shadow-sm text-decoration-none text-dark" href="{% url 'kanban_detail' s.kanban.id %}"><div class="card-body"><div class="d-flex justify-content-between"><strong>{{ s.kanban.name }}</strong><span class="badge text-bg-light">#{{ s.kanban.tag.name }}</span></div><div class="small text-muted mt-2">{% trans "Open" %}: {{ s.open }} · {% trans "Done" %}: {{ s.done }}</div></div></a>{% empty %}<div class="text-muted">{% trans "No Kanbans yet." %}</div>{% endfor %}</div></section>
</div>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends 'core/base.html' %}
{% block content %}
<section class="py-4 py-md-5">
<div class="row align-items-center g-4">
<div class="col-lg-7">
<span class="badge text-bg-dark mb-3">Deutsch</span>
<h1 class="display-5 fw-bold mb-3">my2dos – Aufgaben, Notizen, Links und Teams an einem Ort</h1>
<p class="lead text-muted">my2dos ist eine schnelle persönliche Wissens- und Aufgaben-App. Du schreibst einfach los, nutzt Tags zur Strukturierung und kannst Einträge privat behalten oder mit deinem Team teilen.</p>
<div class="d-flex flex-wrap gap-2 mt-4">
<a class="btn btn-dark btn-lg" href="{% url 'login' %}">Einloggen</a>
<a class="btn btn-outline-secondary btn-lg" href="#tutorial">Tutorial ansehen</a>
</div>
</div>
<div class="col-lg-5">
<div class="card item-card shadow-sm"><div class="card-body">
<div class="small text-muted mb-2">Beispiel</div>
<pre class="mb-0 bg-dark text-white rounded p-3"><code>/todo Deployment vorbereiten #my2dos
/link https://example.com #research
/private Persönliche Idee #my2dos
[[42]] verlinkt einen anderen Eintrag</code></pre>
</div></div>
</div>
</div>
</section>
<section id="tutorial" class="mb-5">
<div class="row g-4">
<div class="col-md-6 col-lg-3"><div class="card item-card h-100 shadow-sm"><div class="card-body"><h2 class="h5">1. Schnell erfassen</h2><p class="text-muted mb-0">Schreibe Text direkt in das Eingabefeld. Ohne Befehl wird daraus eine Notiz.</p></div></div></div>
<div class="col-md-6 col-lg-3"><div class="card item-card h-100 shadow-sm"><div class="card-body"><h2 class="h5">2. Befehle nutzen</h2><p class="text-muted mb-0"><code>/todo</code>, <code>/note</code>, <code>/link</code>, <code>/journal</code>, <code>/public</code> oder <code>/private</code>.</p></div></div></div>
<div class="col-md-6 col-lg-3"><div class="card item-card h-100 shadow-sm"><div class="card-body"><h2 class="h5">3. Mit Tags ordnen</h2><p class="text-muted mb-0">Tags wie <code>#arbeit</code> oder <code>#idee</code> filtern Einträge und können Kanbans bilden.</p></div></div></div>
<div class="col-md-6 col-lg-3"><div class="card item-card h-100 shadow-sm"><div class="card-body"><h2 class="h5">4. Teams teilen</h2><p class="text-muted mb-0">Ein Team-Tag wie <code>#my2dos</code> macht einen Eintrag automatisch für das Team sichtbar.</p></div></div></div>
</div>
</section>
<hr class="my-5">
<section class="py-4">
<div class="row align-items-center g-4">
<div class="col-lg-7">
<span class="badge text-bg-secondary mb-3">English</span>
<h1 class="display-6 fw-bold mb-3">my2dos – tasks, notes, links and teams in one place</h1>
<p class="lead text-muted">my2dos is a fast personal productivity and knowledge app. Capture thoughts quickly, organize them with tags, keep things private or share them with your team.</p>
<a class="btn btn-dark btn-lg" href="{% url 'login' %}">Log in</a>
</div>
<div class="col-lg-5">
<div class="card item-card shadow-sm"><div class="card-body">
<h2 class="h5">Quick tutorial</h2>
<ol class="text-muted mb-0">
<li>Write anything to create a note.</li>
<li>Use <code>/todo</code>, <code>/link</code> or <code>/journal</code> to choose the type.</li>
<li>Add tags like <code>#work</code> to organize and filter.</li>
<li>Use a team tag like <code>#my2dos</code> to create shared team items.</li>
<li>Use <code>/private</code> to force a private item.</li>
</ol>
</div></div>
</div>
</div>
</section>
{% endblock %}
+5 -4
View File
@@ -1,13 +1,14 @@
{% extends 'core/base.html' %}
{% load i18n %}
{% 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 %}
<h1 class="h4 mb-3">{% trans "Login" %}</h1>
{% if form.errors %}<div class="alert alert-danger">{% trans "Username or password is incorrect." %}</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>
<div class="mb-3"><label class="form-label">{% trans "Username" %}</label><input class="form-control" name="username" autofocus></div>
<div class="mb-3"><label class="form-label">{% trans "Password" %}</label><input class="form-control" type="password" name="password"></div>
<button class="btn btn-dark w-100">Login</button>
</form>
</div></div>
+10 -9
View File
@@ -1,18 +1,19 @@
{% extends 'core/base.html' %}
{% load i18n %}
{% block content %}
<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 %}
<input type="hidden" name="next" value="{{ next_url }}">
<div class="card-body vstack gap-3">
<div class="d-flex justify-content-between align-items-center gap-2 sticky-top bg-white py-2" style="z-index:5">
<h1 class="h5 m-0">{% if requested_kind == 'todo' %}Aufgabe erstellen{% elif requested_kind == 'link' %}Link erstellen{% elif requested_kind == 'journal' %}Journal-Eintrag erstellen{% else %}Notiz erstellen{% endif %}</h1>
<div class="d-flex gap-2"><button class="btn btn-success px-3 py-2" title="Speichern">✓</button><a class="btn btn-danger px-3 py-2" href="{{ next_url }}" title="Abbrechen">×</a></div>
<h1 class="h5 m-0">{% if requested_kind == 'todo' %}{% trans "Create task" %}{% elif requested_kind == 'link' %}{% trans "Create link" %}{% elif requested_kind == 'journal' %}{% trans "Create journal entry" %}{% else %}{% trans "Create note" %}{% endif %}</h1>
<div class="d-flex gap-2"><button class="btn btn-success px-3 py-2" title="{% trans 'Save' %}">✓</button><a class="btn btn-danger px-3 py-2" href="{{ next_url }}" title="{% trans 'Cancel' %}">×</a></div>
</div>
<div class="mobile-tag-picker">
<button type="button" class="btn btn-sm btn-outline-secondary" @click="showTags=true">Tags hinzufügen</button>
<button type="button" class="btn btn-sm btn-outline-secondary" @click="showTags=true">{% trans "Add tags" %}</button>
<div class="mobile-tag-backdrop" x-show="showTags" @click="showTags=false" x-cloak></div>
<div class="mobile-tag-popup card shadow" x-show="showTags" x-cloak>
<div class="card-body"><div class="d-flex justify-content-between align-items-center mb-2"><strong>Tag auswählen</strong><button type="button" class="btn-close" @click="showTags=false"></button></div><div class="d-flex flex-wrap gap-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></div>
<div class="card-body"><div class="d-flex justify-content-between align-items-center mb-2"><strong>{% trans "Choose tag" %}</strong><button type="button" class="btn-close" @click="showTags=false"></button></div><div class="d-flex flex-wrap gap-2">{% for tag in tags %}<button type="button" class="btn btn-sm btn-outline-secondary" @click="addToken('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></div>
</div>
</div>
<textarea class="form-control" name="content" rows="14" placeholder="Text … #tag [[ ![[" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)"></textarea>
@@ -20,13 +21,13 @@
<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">Bild hinzufügen</label>
<label class="form-label">{% trans "Add image" %}</label>
<input class="form-control desktop-file" type="file" name="files" multiple accept="image/*,.pdf,.txt,.md" x-ref="fileInput" @change="previewFile($event)">
<div class="mobile-media gap-2"><label class="btn btn-outline-secondary flex-fill mb-0">Galerie<input class="d-none" type="file" name="files" multiple accept="image/*" @change="previewFile($event)"></label><label class="btn btn-outline-secondary flex-fill mb-0">Foto<input class="d-none" type="file" name="files" accept="image/*" capture="environment" @change="previewFile($event)"></label></div>
<div class="form-text">Mit <code>![[</code> können Bilder im Text eingefügt werden.</div>
<div class="mobile-media gap-2"><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Gallery" %}<input class="d-none" type="file" name="files" multiple accept="image/*" @change="previewFile($event)"></label><label class="btn btn-outline-secondary flex-fill mb-0">{% trans "Photo" %}<input class="d-none" type="file" name="files" accept="image/*" capture="environment" @change="previewFile($event)"></label></div>
<div class="form-text">{% trans "Use" %} <code>![[</code> {% trans "to insert images into the text." %}</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 d-none d-md-inline-block">Speichern</button>
<template x-if="preview"><div class="small"><span class="text-muted">{% trans "Image preview:" %}</span><br><img :src="preview" class="img-thumbnail mt-1" style="max-height:180px"></div></template>
<button class="btn btn-dark align-self-start d-none d-md-inline-block">{% trans "Save" %}</button>
</div>
</form>
{% endblock %}
+29 -16
View File
@@ -1,22 +1,23 @@
{% extends 'core/base.html' %}
{% load i18n %}
{% 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="/">← {% trans "back" %}</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>
<h1 class="h4 mb-3">{% trans "User settings" %}</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>
<div><label class="form-label">{% trans "First name" %}</label>{{ profile_form.first_name }}</div>
<div><label class="form-label">{% trans "Last name" %}</label>{{ profile_form.last_name }}</div>
<div><label class="form-label">{% trans "Email" %}</label>{{ profile_form.email }}</div>
<button class="btn btn-dark">{% trans "Save" %}</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">Standardansicht</h2>
<h2 class="h5 mb-3">{% trans "Default view" %}</h2>
<form method="post" class="vstack gap-2">
{% csrf_token %}<input type="hidden" name="action" value="preferences">
{% for field in preference_form %}
@@ -26,28 +27,40 @@
<div><label class="form-label">{{ field.label }}</label>{{ field }}</div>
{% endif %}
{% endfor %}
<button class="btn btn-dark mt-2">Standardansicht speichern</button>
<button class="btn btn-dark mt-2">{% trans "Save default view" %}</button>
</form>
</div></div>
<div class="card item-card shadow-sm mb-3"><div class="card-body">
<h2 class="h5 mb-3">API Key erzeugen</h2>
<h2 id="teams" class="h5 mb-3">{% trans "Teams" %}</h2>
<form method="post" class="vstack gap-3 mb-3">
{% csrf_token %}<input type="hidden" name="action" value="team">
<div><label class="form-label">{% trans "Team name" %}</label>{{ team_form.name }}</div>
<div><label class="form-label">{% trans "Team tag" %}</label><div class="small text-muted mb-2">{% trans "This tag automatically turns items into team items, e.g. #my2dos." %}</div>{{ team_form.slug }}</div>
<button class="btn btn-dark">{% trans "Create team" %}</button>
</form>
<div class="vstack gap-2">
{% for team in teams %}<a class="border rounded p-2 text-decoration-none" href="{% url 'team_detail' team.id %}"><strong>{{ team.name }}</strong> <span class="text-muted">#{{ team.slug }}</span></a>{% empty %}<span class="text-muted">{% trans "No teams yet." %}</span>{% endfor %}
</div>
</div></div>
<div class="card item-card shadow-sm mb-3"><div class="card-body">
<h2 class="h5 mb-3">{% trans "Create API key" %}</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>
<div><label class="form-label">{% trans "Name" %}</label>{{ key_form.name }}</div>
<div><label class="form-label">{% trans "Restrict to tags" %}</label><div class="small text-muted mb-2">{% trans "No selection = access to all own items." %}</div>{{ key_form.tags }}</div>
<button class="btn btn-dark">{% trans "Generate API key" %}</button>
</form>
</div></div>
<div class="card item-card shadow-sm"><div class="card-body">
<h2 class="h5 mb-3">API Keys</h2>
<h2 class="h5 mb-3">{% trans "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>
<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">{% trans "Delete" %}</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 class="small text-muted">{% trans "Tags" %}: {% for tag in key.tags.all %}#{{ tag.name }} {% empty %}{% trans "all" %}{% endfor %}</div>
</div>
{% empty %}<span class="text-muted">Noch keine API Keys.</span>{% endfor %}
{% empty %}<span class="text-muted">{% trans "No API keys yet." %}</span>{% endfor %}
</div>
</div></div>
</section>
+40
View File
@@ -0,0 +1,40 @@
{% extends 'core/base.html' %}
{% load i18n %}
{% block content %}
<a class="btn btn-sm btn-outline-secondary mb-3" href="{% url 'settings' %}">← {% trans "Settings" %}</a>
<div class="d-flex justify-content-between align-items-start gap-3 mb-3">
<div>
<h1 class="h3 mb-1">{% trans "Team" %} {{ team.name }}</h1>
<div class="text-muted">{% trans "Team tag:" %} <a href="/?tag={{ team.slug }}">#{{ team.slug }}</a></div>
</div>
<a class="btn btn-dark" href="/?tag={{ team.slug }}">{% blocktrans with slug=team.slug %}Create item with #{{ slug }}{% endblocktrans %}</a>
</div>
<div class="row g-4">
<section class="col-lg-4">
<div class="card item-card shadow-sm mb-3"><div class="card-body">
<h2 class="h5">{% trans "Members" %}</h2>
<div class="vstack gap-2">
{% for m in memberships %}<div class="border rounded p-2"><strong>{{ m.user.get_full_name|default:m.user.username }}</strong><div class="small text-muted">{{ m.get_role_display }}</div></div>{% endfor %}
</div>
</div></div>
{% if membership.role == 'owner' or membership.role == 'admin' %}
<div class="card item-card shadow-sm"><div class="card-body">
<h2 class="h5">{% trans "Invite" %}</h2>
<form method="post" class="vstack gap-2">
{% csrf_token %}
<div>{{ invite_form.email }}</div>
<button class="btn btn-dark">{% trans "Create invitation link" %}</button>
</form>
<div class="small text-muted mt-3">{% trans "Open invitations:" %}</div>
{% for invite in invites %}<div class="small border rounded p-2 mt-2">{{ invite.email }}<br><code>{{ request.scheme }}://{{ request.get_host }}{% url 'team_accept_invite' invite.token %}</code></div>{% empty %}<div class="small text-muted">{% trans "No open invitations." %}</div>{% endfor %}
</div></div>
{% endif %}
</section>
<section class="col-lg-8">
<h2 class="h5 mb-3">{% trans "Team items" %}</h2>
<div class="vstack gap-3">
{% for item in items %}{% include 'core/_item.html' %}{% empty %}<div class="text-muted">{% blocktrans with slug=team.slug %}No team items yet. Create something with #{{ slug }}.{% endblocktrans %}</div>{% endfor %}
</div>
</section>
</div>
{% endblock %}
+2
View File
@@ -13,6 +13,8 @@ urlpatterns = [
path('kanban/<int:pk>/move/<int:item_pk>/', views.kanban_move, name='kanban_move'),
path('settings/', views.settings_view, name='settings'),
path('settings/api-keys/<int:pk>/delete/', views.delete_api_key, name='delete_api_key'),
path('teams/<int:pk>/', views.team_detail, name='team_detail'),
path('teams/invite/<str:token>/', views.team_accept_invite, name='team_accept_invite'),
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'),
+121 -16
View File
@@ -9,12 +9,14 @@ 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.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
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, KanbanForm, QuickItemForm, UserPreferenceForm, UserSettingsForm
from .models import ApiKey, Attachment, Item, Kanban, Tag, UserPreference
from .forms import ApiKeyForm, EditItemForm, KanbanForm, QuickItemForm, TeamForm, TeamInviteForm, UserPreferenceForm, UserSettingsForm
from .models import ApiKey, Attachment, Item, Kanban, Tag, Team, TeamInvite, TeamMembership, UserPreference
COMMAND_RE = re.compile(r'/(todo|note|link|journal|public|private)\b', re.I)
URL_RE = re.compile(r'https?://\S+')
@@ -66,8 +68,14 @@ def attach_files_and_replace_tokens(item, files):
item.save(update_fields=['content', 'comment', 'updated_at'])
def user_team_ids(user):
if not user.is_authenticated:
return []
return list(user.team_memberships.values_list('team_id', flat=True))
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', 'team').prefetch_related('tags', 'attachments', 'linked_items')
if api_key:
qs = qs.filter(owner=api_key.owner)
allowed_tags = api_key.tags.all()
@@ -75,10 +83,38 @@ def visible_items(user, api_key=None):
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(Q(owner=user) | Q(team_id__in=user_team_ids(user)) | Q(visibility=Item.Visibility.PUBLIC)).distinct()
return qs.filter(visibility=Item.Visibility.PUBLIC)
def user_can_edit_item(user, item):
return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user).exists())
def user_can_delete_item(user, item):
return item.owner_id == user.id or (item.team_id and TeamMembership.objects.filter(team=item.team, user=user, role__in=[TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]).exists())
def apply_team_from_tags(item, user, force_private=False):
if force_private:
item.team = None
if item.visibility == Item.Visibility.TEAM:
item.visibility = Item.Visibility.PRIVATE
item.save(update_fields=['team', 'visibility', 'updated_at'])
return
tag_names = list(item.tags.values_list('name', flat=True))
membership = TeamMembership.objects.select_related('team').filter(user=user, team__slug__in=tag_names).first()
if membership:
item.team = membership.team
if item.visibility == Item.Visibility.PRIVATE:
item.visibility = Item.Visibility.TEAM
else:
item.team = None
if item.visibility == Item.Visibility.TEAM:
item.visibility = Item.Visibility.PRIVATE
item.save(update_fields=['team', 'visibility', 'updated_at'])
def get_api_key(request):
token = request.headers.get('X-Api-Key') or request.headers.get('Authorization', '').removeprefix('Bearer ').strip()
if not token:
@@ -96,18 +132,23 @@ def api_auth_required(view):
return wrapper
@login_required
def index(request):
if not request.user.is_authenticated:
if request.method == 'POST':
return redirect('login')
return render(request, 'core/landing.html')
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'])
raw = form.cleaned_data['content']
kind, visibility, content, url = parse_quick_content(raw)
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)
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I)))
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'
@@ -174,6 +215,7 @@ def new_item(request):
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()
apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I)))
return redirect(next_url)
return render(request, 'core/new_item.html', {
'form': QuickItemForm(), 'tags': Tag.objects.all(), 'requested_kind': requested_kind, 'next_url': next_url,
@@ -192,6 +234,7 @@ def journal(request):
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()
apply_team_from_tags(item, request.user, force_private=bool(re.search(r'/private\b', raw, re.I)))
if request.headers.get('HX-Request'):
return render(request, 'core/_item.html', {'item': item, 'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4})
return redirect('journal')
@@ -225,14 +268,14 @@ def settings_view(request):
form = UserSettingsForm(request.POST, instance=request.user)
if form.is_valid():
form.save()
messages.success(request, 'Einstellungen gespeichert.')
messages.success(request, _('Settings saved.'))
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.')
messages.success(request, _('Default view saved.'))
return redirect('settings')
elif request.POST.get('action') == 'apikey':
key_form = ApiKeyForm(request.POST)
@@ -241,14 +284,26 @@ def settings_view(request):
api_key.owner = request.user
api_key.save()
key_form.save_m2m()
messages.success(request, f'API Key erzeugt: {api_key.token}')
messages.success(request, _('API key created: %(token)s') % {'token': api_key.token})
return redirect('settings')
elif request.POST.get('action') == 'team':
team_form = TeamForm(request.POST)
if team_form.is_valid():
team = team_form.save(commit=False)
team.owner = request.user
team.save()
TeamMembership.objects.create(team=team, user=request.user, role=TeamMembership.Role.OWNER)
Tag.objects.get_or_create(name=team.slug)
messages.success(request, _('Team %(team)s created. Use #%(slug)s for team items.') % {'team': team.name, 'slug': team.slug})
return redirect('team_detail', pk=team.pk)
prefs, _ = UserPreference.objects.get_or_create(user=request.user)
return render(request, 'core/settings.html', {
'profile_form': UserSettingsForm(instance=request.user),
'preference_form': UserPreferenceForm(instance=prefs),
'key_form': ApiKeyForm(),
'team_form': TeamForm(),
'api_keys': request.user.api_keys.prefetch_related('tags'),
'teams': Team.objects.filter(memberships__user=request.user).distinct(),
})
@@ -260,6 +315,41 @@ def delete_api_key(request, pk):
return redirect('settings')
@login_required
def team_detail(request, pk):
membership = get_object_or_404(TeamMembership.objects.select_related('team'), team_id=pk, user=request.user)
team = membership.team
if request.method == 'POST' and membership.role in [TeamMembership.Role.OWNER, TeamMembership.Role.ADMIN]:
invite_form = TeamInviteForm(request.POST)
if invite_form.is_valid():
invite = invite_form.save(commit=False)
invite.team = team
invite.invited_by = request.user
invite.save()
messages.success(request, _('Invitation link created: %(url)s') % {'url': request.build_absolute_uri(reverse("team_accept_invite", args=[invite.token]))})
return redirect('team_detail', pk=team.pk)
items = visible_items(request.user).filter(team=team)
return render(request, 'core/team_detail.html', {
'team': team,
'membership': membership,
'memberships': team.memberships.select_related('user'),
'invites': team.invites.filter(accepted_at__isnull=True),
'invite_form': TeamInviteForm(),
'items': items,
'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4,
})
@login_required
def team_accept_invite(request, token):
invite = get_object_or_404(TeamInvite.objects.select_related('team'), token=token, accepted_at__isnull=True)
TeamMembership.objects.get_or_create(team=invite.team, user=request.user, defaults={'role': TeamMembership.Role.MEMBER})
invite.accepted_at = timezone.now()
invite.save(update_fields=['accepted_at'])
messages.success(request, _('You are now a member of team %(team)s.') % {'team': invite.team.name})
return redirect('team_detail', pk=invite.team.pk)
@login_required
def item_detail(request, pk):
item = get_object_or_404(visible_items(request.user), pk=pk)
@@ -269,7 +359,9 @@ def item_detail(request, pk):
@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 = get_object_or_404(visible_items(request.user), pk=pk, kind=Item.Kind.TODO)
if not user_can_edit_item(request.user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
item.is_done = not item.is_done
item.completed_at = timezone.now() if item.is_done else None
item.save(update_fields=['is_done', 'completed_at', 'updated_at'])
@@ -286,13 +378,16 @@ def item_card(request, pk):
@login_required
def edit_item(request, pk):
item = get_object_or_404(Item, pk=pk, owner=request.user)
item = get_object_or_404(visible_items(request.user), pk=pk)
if not user_can_edit_item(request.user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
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()
apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
response = render(request, 'core/_item.html', {'item': item, 'overview_lines': request.user.preferences.overview_lines if hasattr(request.user, 'preferences') else 4})
response['HX-Trigger'] = 'tagsChanged'
return response
@@ -301,7 +396,9 @@ def edit_item(request, pk):
@login_required
def item_editor(request, pk):
item = get_object_or_404(Item, pk=pk, owner=request.user)
item = get_object_or_404(visible_items(request.user), pk=pk)
if not user_can_edit_item(request.user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
next_url = request.GET.get('next') or item.get_absolute_url()
if request.method == 'POST':
form = EditItemForm(request.POST, request.FILES, instance=item)
@@ -309,6 +406,7 @@ def item_editor(request, pk):
item = form.save()
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
item.sync_metadata()
apply_team_from_tags(item, request.user, force_private=item.visibility == Item.Visibility.PRIVATE)
return redirect(next_url)
return render(request, 'core/item_editor.html', {'item': item, 'tags': Tag.objects.all(), 'next_url': next_url})
@@ -316,7 +414,9 @@ def item_editor(request, pk):
@login_required
@require_POST
def delete_item(request, pk):
item = get_object_or_404(Item, pk=pk, owner=request.user)
item = get_object_or_404(visible_items(request.user), pk=pk)
if not user_can_delete_item(request.user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
item.delete()
return JsonResponse({'ok': True})
@@ -378,7 +478,9 @@ def api_tags(request):
@login_required
def api_attachment_suggestions(request):
item = get_object_or_404(Item, pk=request.GET.get('item'), owner=request.user)
item = get_object_or_404(visible_items(request.user), pk=request.GET.get('item'))
if not user_can_edit_item(request.user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
data = [{'id': a.id, 'name': a.file.name.split('/')[-1], 'url': a.file.url} for a in item.attachments.all()]
return JsonResponse({'attachments': data})
@@ -400,6 +502,7 @@ def serialize_item(item):
'content': item.content,
'comment': item.comment,
'visibility': item.visibility,
'team': item.team.slug if item.team else None,
'is_done': item.is_done,
'completed_at': item.completed_at.isoformat() if item.completed_at else None,
'due_at': item.due_at.isoformat() if item.due_at else None,
@@ -424,6 +527,7 @@ def api_items(request):
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()
apply_team_from_tags(item, user, force_private=bool(re.search(r'/private\b', raw, re.I)))
return JsonResponse(serialize_item(item), status=201)
@@ -436,11 +540,11 @@ def api_item_detail(request, pk):
if request.method == 'GET':
return JsonResponse(serialize_item(item))
if request.method == 'DELETE':
if item.owner != user:
if not user_can_delete_item(user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
item.delete()
return JsonResponse({'ok': True})
if item.owner != user:
if not user_can_edit_item(user, item):
return JsonResponse({'error': 'forbidden'}, status=403)
data = json.loads(request.body or '{}')
for field in ['content', 'comment', 'kind', 'visibility', 'is_done', 'due_at', 'url']:
@@ -450,4 +554,5 @@ def api_item_detail(request, pk):
item.completed_at = timezone.now() if item.is_done else None
item.save()
item.sync_metadata()
apply_team_from_tags(item, user, force_private=item.visibility == Item.Visibility.PRIVATE)
return JsonResponse(serialize_item(item))
+517
View File
@@ -0,0 +1,517 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-23 14:50+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: de\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: core/forms.py:28
msgid "Write something … /todo /link /public #tag [[note:1]]"
msgstr "Schreibe etwas … /todo /link /public #tag [[note:1]]"
#: core/forms.py:65
msgid "Show notes"
msgstr "Notizen anzeigen"
#: core/forms.py:66
msgid "Show open tasks"
msgstr "Offene Aufgaben anzeigen"
#: core/forms.py:67
msgid "Show completed tasks"
msgstr "Erledigte Aufgaben anzeigen"
#: core/forms.py:68
msgid "Show links"
msgstr "Links anzeigen"
#: core/forms.py:69
msgid "Show journal"
msgstr "Journal anzeigen"
#: core/forms.py:70
msgid "Lines in overview"
msgstr "Zeilen in der Übersicht"
#: core/forms.py:79 core/templates/core/settings.html:37
msgid "Team name"
msgstr "Teamname"
#: core/forms.py:79 core/templates/core/settings.html:38
msgid "Team tag"
msgstr "Team-Tag"
#: core/forms.py:93 core/templates/core/settings.html:13
msgid "Email"
msgstr "E-Mail"
#: core/forms.py:98
msgid "Tag"
msgstr "Tag"
#: core/forms.py:103
msgid "Kanban name"
msgstr "Name des Kanbans"
#: core/forms.py:117
msgid "API key name"
msgstr "Name des API Keys"
#: core/models.py:75 core/templates/core/index.html:5
#: core/templates/core/index.html:53 core/templates/core/journal.html:25
msgid "Task"
msgstr "Aufgabe"
#: core/models.py:76 core/templates/core/index.html:6
#: core/templates/core/index.html:54 core/templates/core/journal.html:26
msgid "Note"
msgstr "Notiz"
#: core/models.py:77
msgid "Link"
msgstr "Link"
#: core/models.py:78 core/templates/core/base.html:24
msgid "Journal"
msgstr "Journal"
#: core/models.py:81 core/templates/core/_edit_form.html:20
#: core/templates/core/item_editor.html:25
msgid "Private"
msgstr "Privat"
#: core/models.py:82 core/templates/core/team_detail.html:7
msgid "Team"
msgstr "Team"
#: core/models.py:83 core/templates/core/_edit_form.html:21
#: core/templates/core/item_editor.html:25
msgid "Public"
msgstr "Öffentlich"
#: core/models.py:92 core/templates/core/kanban_detail.html:7
msgid "Inbox"
msgstr "Eingang"
#: core/models.py:94
msgid "In Progress"
msgstr "In Bearbeitung"
#: core/models.py:95 core/templates/core/kanban_detail.html:10
#: core/templates/core/kanban_list.html:7
msgid "Done"
msgstr "Erledigt"
#: core/templates/core/_edit_form.html:5
#: core/templates/core/item_editor.html:11
msgid "Type:"
msgstr "Typ:"
#: core/templates/core/_edit_form.html:7 core/templates/core/index.html:58
#: core/templates/core/item_editor.html:13 core/templates/core/journal.html:29
#: core/templates/core/new_item.html:13
msgid "Add tags"
msgstr "Tags hinzufügen"
#: core/templates/core/_edit_form.html:10
#: core/templates/core/item_editor.html:16 core/templates/core/new_item.html:16
msgid "Choose tag"
msgstr "Tag auswählen"
#: core/templates/core/_edit_form.html:14
msgid "Comment, e.g. same task already captured [[12]]"
msgstr "Kommentar, z.B. gleiche Aufgabe bereits erfasst [[12]]"
#: core/templates/core/_edit_form.html:23
msgid "Date/time for todo"
msgstr "Datum/Zeit für Todo"
#: core/templates/core/_edit_form.html:27 core/templates/core/index.html:69
msgid "Image from clipboard:"
msgstr "Bild aus Zwischenablage:"
#: core/templates/core/_edit_form.html:29 core/templates/core/index.html:67
#: core/templates/core/item_editor.html:9
#: core/templates/core/item_editor.html:31 core/templates/core/journal.html:32
#: core/templates/core/new_item.html:10 core/templates/core/new_item.html:30
#: core/templates/core/settings.html:14
msgid "Save"
msgstr "Speichern"
#: core/templates/core/_edit_form.html:30
msgid "Open in editor"
msgstr "Im Editor öffnen"
#: core/templates/core/_edit_form.html:31
#: core/templates/core/item_editor.html:9
#: core/templates/core/item_editor.html:31 core/templates/core/new_item.html:10
msgid "Cancel"
msgstr "Abbrechen"
#: core/templates/core/_item.html:14
msgid "show less"
msgstr "weniger anzeigen"
#: core/templates/core/_item.html:14
msgid "show more"
msgstr "mehr anzeigen"
#: core/templates/core/_item.html:17
msgid "Due:"
msgstr "Fällig:"
#: core/templates/core/_item.html:19
msgid "Links:"
msgstr "Links:"
#: core/templates/core/_item.html:21
msgid "Attachments:"
msgstr "Anhänge:"
#: core/templates/core/_tags.html:9 core/templates/core/index.html:44
msgid "No tags yet"
msgstr "Noch keine Tags"
#: core/templates/core/base.html:24
msgid "Notes · Tasks · Links"
msgstr "Notizen · Aufgaben · Links"
#: core/templates/core/base.html:24
msgid "Kanban"
msgstr "Kanban"
#: core/templates/core/base.html:24 core/templates/core/settings.html:34
msgid "Teams"
msgstr "Teams"
#: core/templates/core/base.html:24 core/templates/core/team_detail.html:4
msgid "Settings"
msgstr "Einstellungen"
#: core/templates/core/base.html:24
msgid "Install app"
msgstr "App installieren"
#: core/templates/core/base.html:24
msgid "Logout"
msgstr "Logout"
#: core/templates/core/base.html:24 core/templates/core/login.html:6
msgid "Login"
msgstr "Login"
#: core/templates/core/base.html:32 core/templates/core/index.html:99
#: core/templates/core/new_item.html:9
msgid "Create task"
msgstr "Aufgabe erstellen"
#: core/templates/core/base.html:32 core/templates/core/index.html:100
#, fuzzy
#| msgid "Create link"
msgid "Save link"
msgstr "Link erstellen"
#: core/templates/core/base.html:32 core/templates/core/index.html:101
#, fuzzy
#| msgid "Create journal entry"
msgid "Journal entry"
msgstr "Journal-Eintrag erstellen"
#: core/templates/core/base.html:32 core/templates/core/index.html:102
#, fuzzy
#| msgid "Public"
msgid "public"
msgstr "Öffentlich"
#: core/templates/core/base.html:32 core/templates/core/index.html:103
#, fuzzy
#| msgid "Private"
msgid "private"
msgstr "Privat"
#: core/templates/core/base.html:32 core/templates/core/index.html:104
msgid "Insert Markdown code block"
msgstr "Markdown Codeblock einfügen"
#: core/templates/core/base.html:32 core/templates/core/index.html:105
msgid "Search internal task/note"
msgstr "interne Aufgabe/Notiz suchen"
#: core/templates/core/base.html:32 core/templates/core/index.html:106
#, fuzzy
#| msgid "to insert images into the text."
msgid "Insert image from attachments"
msgstr "können Bilder im Text eingefügt werden."
#: core/templates/core/base.html:33 core/templates/core/index.html:113
#, fuzzy
#| msgid "Image preview:"
msgid "Image:"
msgstr "Bildvorschau:"
#: core/templates/core/detail.html:4 core/templates/core/kanban_list.html:4
#: core/templates/core/settings.html:4
msgid "back"
msgstr "zurück"
#: core/templates/core/index.html:13
msgid "Filter"
msgstr "Filter"
#: core/templates/core/index.html:16
msgid "Notes"
msgstr "Notizen"
#: core/templates/core/index.html:18 core/templates/core/kanban_list.html:7
msgid "Open"
msgstr "Offen"
#: core/templates/core/index.html:19
msgid "Archive"
msgstr "Archiv"
#: core/templates/core/index.html:26
msgid "Search (Ctrl+F, # for tags)"
msgstr "Suche (Ctrl+F, # für Tags)"
#: core/templates/core/index.html:27
msgid "Clear search"
msgstr "Suche löschen"
#: core/templates/core/index.html:34 core/templates/core/journal.html:15
#: core/templates/core/settings.html:61
msgid "Tags"
msgstr "Tags"
#: core/templates/core/index.html:40
msgid "Show tags"
msgstr "Tags anzeigen"
#: core/templates/core/index.html:65 core/templates/core/journal.html:32
#: core/templates/core/new_item.html:26
msgid "Gallery"
msgstr "Galerie"
#: core/templates/core/index.html:65 core/templates/core/journal.html:32
#: core/templates/core/new_item.html:26
msgid "Photo"
msgstr "Foto"
#: core/templates/core/index.html:66
msgid "Editor"
msgstr "Editor"
#: core/templates/core/index.html:72
msgid "Nothing here yet."
msgstr "Noch nichts vorhanden."
#: core/templates/core/item_editor.html:8
#, python-format
msgid "Edit item #%(id)s"
msgstr "Eintrag #%(id)s bearbeiten"
#: core/templates/core/item_editor.html:20
msgid "Comment"
msgstr "Kommentar"
#: core/templates/core/item_editor.html:30 core/templates/core/new_item.html:29
msgid "Image preview:"
msgstr "Bildvorschau:"
#: core/templates/core/journal.html:35
msgid "No journal activity on this day."
msgstr "Keine Journal-Aktivität an diesem Tag."
#: core/templates/core/kanban_list.html:6
msgid "New Kanban"
msgstr "Neues Kanban"
#: core/templates/core/kanban_list.html:6
msgid "Create"
msgstr "Anlegen"
#: core/templates/core/kanban_list.html:7
msgid "No Kanbans yet."
msgstr "Noch keine Kanbans."
#: core/templates/core/login.html:7
msgid "Username or password is incorrect."
msgstr "Benutzername oder Passwort ist falsch."
#: core/templates/core/login.html:10
msgid "Username"
msgstr "Benutzername"
#: core/templates/core/login.html:11
msgid "Password"
msgstr "Passwort"
#: core/templates/core/new_item.html:9
msgid "Create link"
msgstr "Link erstellen"
#: core/templates/core/new_item.html:9
msgid "Create journal entry"
msgstr "Journal-Eintrag erstellen"
#: core/templates/core/new_item.html:9
msgid "Create note"
msgstr "Notiz erstellen"
#: core/templates/core/new_item.html:24
msgid "Add image"
msgstr "Bild hinzufügen"
#: core/templates/core/new_item.html:27
msgid "Use"
msgstr "Mit"
#: core/templates/core/new_item.html:27
msgid "to insert images into the text."
msgstr "können Bilder im Text eingefügt werden."
#: core/templates/core/settings.html:8
msgid "User settings"
msgstr "Benutzereinstellungen"
#: core/templates/core/settings.html:11
msgid "First name"
msgstr "Vorname"
#: core/templates/core/settings.html:12
msgid "Last name"
msgstr "Name"
#: core/templates/core/settings.html:20
msgid "Default view"
msgstr "Standardansicht"
#: core/templates/core/settings.html:30
msgid "Save default view"
msgstr "Standardansicht speichern"
#: core/templates/core/settings.html:38
msgid "This tag automatically turns items into team items, e.g. #my2dos."
msgstr "Dieser Tag macht Einträge automatisch zu Team-Einträgen, z.B. #my2dos."
#: core/templates/core/settings.html:39
msgid "Create team"
msgstr "Team erstellen"
#: core/templates/core/settings.html:42
msgid "No teams yet."
msgstr "Noch keine Teams."
#: core/templates/core/settings.html:46
msgid "Create API key"
msgstr "API Key erzeugen"
#: core/templates/core/settings.html:49
msgid "Name"
msgstr "Name"
#: core/templates/core/settings.html:50
msgid "Restrict to tags"
msgstr "Auf Tags beschränken"
#: core/templates/core/settings.html:50
msgid "No selection = access to all own items."
msgstr "Keine Auswahl = Zugriff auf alle eigenen Einträge."
#: core/templates/core/settings.html:51
msgid "Generate API key"
msgstr "API Key generieren"
#: core/templates/core/settings.html:55
msgid "API keys"
msgstr "API Keys"
#: core/templates/core/settings.html:59
msgid "Delete"
msgstr "Löschen"
#: core/templates/core/settings.html:61
msgid "all"
msgstr "alle"
#: core/templates/core/settings.html:63
msgid "No API keys yet."
msgstr "Noch keine API Keys."
#: core/templates/core/team_detail.html:8
msgid "Team tag:"
msgstr "Team-Tag:"
#: core/templates/core/team_detail.html:10
#, python-format
msgid "Create item with #%(slug)s"
msgstr "Eintrag mit #%(slug)s erstellen"
#: core/templates/core/team_detail.html:15
msgid "Members"
msgstr "Mitglieder"
#: core/templates/core/team_detail.html:22
msgid "Invite"
msgstr "Einladen"
#: core/templates/core/team_detail.html:26
msgid "Create invitation link"
msgstr "Einladungslink erzeugen"
#: core/templates/core/team_detail.html:28
msgid "Open invitations:"
msgstr "Offene Einladungen:"
#: core/templates/core/team_detail.html:29
msgid "No open invitations."
msgstr "Keine offenen Einladungen."
#: core/templates/core/team_detail.html:34
msgid "Team items"
msgstr "Team-Einträge"
#: core/templates/core/team_detail.html:36
#, python-format
msgid "No team items yet. Create something with #%(slug)s."
msgstr "Noch keine Team-Einträge. Erstelle etwas mit #%(slug)s."
#: core/views.py:271
msgid "Settings saved."
msgstr "Einstellungen gespeichert."
#: core/views.py:278
msgid "Default view saved."
msgstr "Standardansicht gespeichert."
#: core/views.py:287
#, python-format
msgid "API key created: %(token)s"
msgstr "API Key erzeugt: %(token)s"
#: core/views.py:297
#, python-format
msgid "Team %(team)s created. Use #%(slug)s for team items."
msgstr "Team %(team)s erstellt. Nutze #%(slug)s für Team-Einträge."
#: core/views.py:329
#, python-format
msgid "Invitation link created: %(url)s"
msgstr "Einladungslink erzeugt: %(url)s"
#: core/views.py:349
#, python-format
msgid "You are now a member of team %(team)s."
msgstr "Du bist jetzt Mitglied im Team %(team)s."
+506
View File
@@ -0,0 +1,506 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-23 14:50+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: core/forms.py:28
msgid "Write something … /todo /link /public #tag [[note:1]]"
msgstr ""
#: core/forms.py:65
msgid "Show notes"
msgstr ""
#: core/forms.py:66
msgid "Show open tasks"
msgstr ""
#: core/forms.py:67
msgid "Show completed tasks"
msgstr ""
#: core/forms.py:68
msgid "Show links"
msgstr ""
#: core/forms.py:69
msgid "Show journal"
msgstr ""
#: core/forms.py:70
msgid "Lines in overview"
msgstr ""
#: core/forms.py:79 core/templates/core/settings.html:37
msgid "Team name"
msgstr ""
#: core/forms.py:79 core/templates/core/settings.html:38
msgid "Team tag"
msgstr ""
#: core/forms.py:93 core/templates/core/settings.html:13
msgid "Email"
msgstr ""
#: core/forms.py:98
msgid "Tag"
msgstr ""
#: core/forms.py:103
msgid "Kanban name"
msgstr ""
#: core/forms.py:117
msgid "API key name"
msgstr ""
#: core/models.py:75 core/templates/core/index.html:5
#: core/templates/core/index.html:53 core/templates/core/journal.html:25
msgid "Task"
msgstr ""
#: core/models.py:76 core/templates/core/index.html:6
#: core/templates/core/index.html:54 core/templates/core/journal.html:26
msgid "Note"
msgstr ""
#: core/models.py:77
msgid "Link"
msgstr ""
#: core/models.py:78 core/templates/core/base.html:24
msgid "Journal"
msgstr ""
#: core/models.py:81 core/templates/core/_edit_form.html:20
#: core/templates/core/item_editor.html:25
msgid "Private"
msgstr ""
#: core/models.py:82 core/templates/core/team_detail.html:7
msgid "Team"
msgstr ""
#: core/models.py:83 core/templates/core/_edit_form.html:21
#: core/templates/core/item_editor.html:25
msgid "Public"
msgstr ""
#: core/models.py:92 core/templates/core/kanban_detail.html:7
msgid "Inbox"
msgstr ""
#: core/models.py:94
msgid "In Progress"
msgstr ""
#: core/models.py:95 core/templates/core/kanban_detail.html:10
#: core/templates/core/kanban_list.html:7
msgid "Done"
msgstr ""
#: core/templates/core/_edit_form.html:5
#: core/templates/core/item_editor.html:11
msgid "Type:"
msgstr ""
#: core/templates/core/_edit_form.html:7 core/templates/core/index.html:58
#: core/templates/core/item_editor.html:13 core/templates/core/journal.html:29
#: core/templates/core/new_item.html:13
msgid "Add tags"
msgstr ""
#: core/templates/core/_edit_form.html:10
#: core/templates/core/item_editor.html:16 core/templates/core/new_item.html:16
msgid "Choose tag"
msgstr ""
#: core/templates/core/_edit_form.html:14
msgid "Comment, e.g. same task already captured [[12]]"
msgstr ""
#: core/templates/core/_edit_form.html:23
msgid "Date/time for todo"
msgstr ""
#: core/templates/core/_edit_form.html:27 core/templates/core/index.html:69
msgid "Image from clipboard:"
msgstr ""
#: core/templates/core/_edit_form.html:29 core/templates/core/index.html:67
#: core/templates/core/item_editor.html:9
#: core/templates/core/item_editor.html:31 core/templates/core/journal.html:32
#: core/templates/core/new_item.html:10 core/templates/core/new_item.html:30
#: core/templates/core/settings.html:14
msgid "Save"
msgstr ""
#: core/templates/core/_edit_form.html:30
msgid "Open in editor"
msgstr ""
#: core/templates/core/_edit_form.html:31
#: core/templates/core/item_editor.html:9
#: core/templates/core/item_editor.html:31 core/templates/core/new_item.html:10
msgid "Cancel"
msgstr ""
#: core/templates/core/_item.html:14
msgid "show less"
msgstr ""
#: core/templates/core/_item.html:14
msgid "show more"
msgstr ""
#: core/templates/core/_item.html:17
msgid "Due:"
msgstr ""
#: core/templates/core/_item.html:19
msgid "Links:"
msgstr ""
#: core/templates/core/_item.html:21
msgid "Attachments:"
msgstr ""
#: core/templates/core/_tags.html:9 core/templates/core/index.html:44
msgid "No tags yet"
msgstr ""
#: core/templates/core/base.html:24
msgid "Notes · Tasks · Links"
msgstr ""
#: core/templates/core/base.html:24
msgid "Kanban"
msgstr ""
#: core/templates/core/base.html:24 core/templates/core/settings.html:34
msgid "Teams"
msgstr ""
#: core/templates/core/base.html:24 core/templates/core/team_detail.html:4
msgid "Settings"
msgstr ""
#: core/templates/core/base.html:24
msgid "Install app"
msgstr ""
#: core/templates/core/base.html:24
msgid "Logout"
msgstr ""
#: core/templates/core/base.html:24 core/templates/core/login.html:6
msgid "Login"
msgstr ""
#: core/templates/core/base.html:32 core/templates/core/index.html:99
#: core/templates/core/new_item.html:9
msgid "Create task"
msgstr ""
#: core/templates/core/base.html:32 core/templates/core/index.html:100
msgid "Save link"
msgstr ""
#: core/templates/core/base.html:32 core/templates/core/index.html:101
msgid "Journal entry"
msgstr ""
#: core/templates/core/base.html:32 core/templates/core/index.html:102
msgid "public"
msgstr ""
#: core/templates/core/base.html:32 core/templates/core/index.html:103
msgid "private"
msgstr ""
#: core/templates/core/base.html:32 core/templates/core/index.html:104
msgid "Insert Markdown code block"
msgstr ""
#: core/templates/core/base.html:32 core/templates/core/index.html:105
msgid "Search internal task/note"
msgstr ""
#: core/templates/core/base.html:32 core/templates/core/index.html:106
msgid "Insert image from attachments"
msgstr ""
#: core/templates/core/base.html:33 core/templates/core/index.html:113
msgid "Image:"
msgstr ""
#: core/templates/core/detail.html:4 core/templates/core/kanban_list.html:4
#: core/templates/core/settings.html:4
msgid "back"
msgstr ""
#: core/templates/core/index.html:13
msgid "Filter"
msgstr ""
#: core/templates/core/index.html:16
msgid "Notes"
msgstr ""
#: core/templates/core/index.html:18 core/templates/core/kanban_list.html:7
msgid "Open"
msgstr ""
#: core/templates/core/index.html:19
msgid "Archive"
msgstr ""
#: core/templates/core/index.html:26
msgid "Search (Ctrl+F, # for tags)"
msgstr ""
#: core/templates/core/index.html:27
msgid "Clear search"
msgstr ""
#: core/templates/core/index.html:34 core/templates/core/journal.html:15
#: core/templates/core/settings.html:61
msgid "Tags"
msgstr ""
#: core/templates/core/index.html:40
msgid "Show tags"
msgstr ""
#: core/templates/core/index.html:65 core/templates/core/journal.html:32
#: core/templates/core/new_item.html:26
msgid "Gallery"
msgstr ""
#: core/templates/core/index.html:65 core/templates/core/journal.html:32
#: core/templates/core/new_item.html:26
msgid "Photo"
msgstr ""
#: core/templates/core/index.html:66
msgid "Editor"
msgstr ""
#: core/templates/core/index.html:72
msgid "Nothing here yet."
msgstr ""
#: core/templates/core/item_editor.html:8
#, python-format
msgid "Edit item #%(id)s"
msgstr ""
#: core/templates/core/item_editor.html:20
msgid "Comment"
msgstr ""
#: core/templates/core/item_editor.html:30 core/templates/core/new_item.html:29
msgid "Image preview:"
msgstr ""
#: core/templates/core/journal.html:35
msgid "No journal activity on this day."
msgstr ""
#: core/templates/core/kanban_list.html:6
msgid "New Kanban"
msgstr ""
#: core/templates/core/kanban_list.html:6
msgid "Create"
msgstr ""
#: core/templates/core/kanban_list.html:7
msgid "No Kanbans yet."
msgstr ""
#: core/templates/core/login.html:7
msgid "Username or password is incorrect."
msgstr ""
#: core/templates/core/login.html:10
msgid "Username"
msgstr ""
#: core/templates/core/login.html:11
msgid "Password"
msgstr ""
#: core/templates/core/new_item.html:9
msgid "Create link"
msgstr ""
#: core/templates/core/new_item.html:9
msgid "Create journal entry"
msgstr ""
#: core/templates/core/new_item.html:9
msgid "Create note"
msgstr ""
#: core/templates/core/new_item.html:24
msgid "Add image"
msgstr ""
#: core/templates/core/new_item.html:27
msgid "Use"
msgstr ""
#: core/templates/core/new_item.html:27
msgid "to insert images into the text."
msgstr ""
#: core/templates/core/settings.html:8
msgid "User settings"
msgstr ""
#: core/templates/core/settings.html:11
msgid "First name"
msgstr ""
#: core/templates/core/settings.html:12
msgid "Last name"
msgstr ""
#: core/templates/core/settings.html:20
msgid "Default view"
msgstr ""
#: core/templates/core/settings.html:30
msgid "Save default view"
msgstr ""
#: core/templates/core/settings.html:38
msgid "This tag automatically turns items into team items, e.g. #my2dos."
msgstr ""
#: core/templates/core/settings.html:39
msgid "Create team"
msgstr ""
#: core/templates/core/settings.html:42
msgid "No teams yet."
msgstr ""
#: core/templates/core/settings.html:46
msgid "Create API key"
msgstr ""
#: core/templates/core/settings.html:49
msgid "Name"
msgstr ""
#: core/templates/core/settings.html:50
msgid "Restrict to tags"
msgstr ""
#: core/templates/core/settings.html:50
msgid "No selection = access to all own items."
msgstr ""
#: core/templates/core/settings.html:51
msgid "Generate API key"
msgstr ""
#: core/templates/core/settings.html:55
msgid "API keys"
msgstr ""
#: core/templates/core/settings.html:59
msgid "Delete"
msgstr ""
#: core/templates/core/settings.html:61
msgid "all"
msgstr ""
#: core/templates/core/settings.html:63
msgid "No API keys yet."
msgstr ""
#: core/templates/core/team_detail.html:8
msgid "Team tag:"
msgstr ""
#: core/templates/core/team_detail.html:10
#, python-format
msgid "Create item with #%(slug)s"
msgstr ""
#: core/templates/core/team_detail.html:15
msgid "Members"
msgstr ""
#: core/templates/core/team_detail.html:22
msgid "Invite"
msgstr ""
#: core/templates/core/team_detail.html:26
msgid "Create invitation link"
msgstr ""
#: core/templates/core/team_detail.html:28
msgid "Open invitations:"
msgstr ""
#: core/templates/core/team_detail.html:29
msgid "No open invitations."
msgstr ""
#: core/templates/core/team_detail.html:34
msgid "Team items"
msgstr ""
#: core/templates/core/team_detail.html:36
#, python-format
msgid "No team items yet. Create something with #%(slug)s."
msgstr ""
#: core/views.py:271
msgid "Settings saved."
msgstr ""
#: core/views.py:278
msgid "Default view saved."
msgstr ""
#: core/views.py:287
#, python-format
msgid "API key created: %(token)s"
msgstr ""
#: core/views.py:297
#, python-format
msgid "Team %(team)s created. Use #%(slug)s for team items."
msgstr ""
#: core/views.py:329
#, python-format
msgid "Invitation link created: %(url)s"
msgstr ""
#: core/views.py:349
#, python-format
msgid "You are now a member of team %(team)s."
msgstr ""
+7 -1
View File
@@ -24,6 +24,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
@@ -47,7 +48,12 @@ WSGI_APPLICATION = 'my2dos.wsgi.application'
DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3'}}
AUTH_PASSWORD_VALIDATORS = []
LANGUAGE_CODE = 'de-de'
LANGUAGE_CODE = 'de'
LANGUAGES = [
('de', 'Deutsch'),
('en', 'English'),
]
LOCALE_PATHS = [BASE_DIR / 'locale']
TIME_ZONE = 'Europe/Berlin'
USE_I18N = True
USE_TZ = True
+1
View File
@@ -12,6 +12,7 @@ def service_worker(request):
urlpatterns = [
path('sw.js', service_worker, name='service_worker'),
path('i18n/', include('django.conf.urls.i18n')),
path('admin/', admin.site.urls),
path('', include('core.urls')),
]