Add teams and internationalization
This commit is contained in:
+6
-3
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()}}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(()=>{
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`});}}}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user