Add PWA and mobile creation flow
This commit is contained in:
@@ -2,6 +2,13 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<div class="card-body vstack gap-2">
|
<div class="card-body vstack gap-2">
|
||||||
<div class="small text-muted">Typ: {{ item.get_kind_display }}</div>
|
<div class="small text-muted">Typ: {{ item.get_kind_display }}</div>
|
||||||
|
<div class="mobile-tag-picker">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" @click="showTags=true">Tags hinzufügen</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="insert('#{{ tag.name|escapejs }}'); showTags=false">#{{ 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="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="Kommentar, z.B. gleiche Aufgabe bereits erfasst [[12]]" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.comment }}</textarea>
|
||||||
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
|
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{% load markdown_extras %}
|
{% load markdown_extras %}
|
||||||
<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 }}'">
|
<article id="item-{{ item.id }}" class="card item-card shadow-sm {% if item.is_done %}done{% endif %}" ondblclick="if(!event.target.closest('a,button,input,textarea,select,label')) window.location.href='{{ item.get_absolute_url }}?next={{ request.get_full_path|urlencode }}'">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="d-flex justify-content-between align-items-start gap-3">
|
<div class="item-row d-flex justify-content-between align-items-start gap-3">
|
||||||
<div class="flex-grow-1 min-w-0" style="min-width:0">
|
<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">
|
<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 {% if item.kind == 'todo' %}text-bg-primary{% elif item.kind == 'link' %}text-bg-success{% elif item.kind == 'journal' %}text-bg-info{% else %}text-bg-secondary{% endif %}">{{ item.get_kind_display }}</span>
|
||||||
<span class="badge text-bg-light">{{ item.get_visibility_display }}</span>
|
<span class="badge text-bg-light">{{ item.get_visibility_display }}</span>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="item-actions btn-group btn-group-sm">
|
||||||
{% if item.kind == 'todo' %}<button class="btn btn-outline-primary" hx-post="{% url 'toggle_done' item.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-target="#item-{{ item.id }}" hx-swap="outerHTML">✓</button>{% endif %}
|
{% if item.kind == 'todo' %}<button class="btn btn-outline-primary" hx-post="{% url 'toggle_done' item.id %}{% if request.GET.urlencode %}?{{ request.GET.urlencode }}{% endif %}" hx-target="#item-{{ item.id }}" hx-swap="outerHTML">✓</button>{% endif %}
|
||||||
{% if request.resolver_match.url_name == 'item_detail' %}
|
{% if request.resolver_match.url_name == 'item_detail' %}
|
||||||
<a class="btn btn-outline-secondary" href="{% url 'item_editor' item.id %}?next={{ request.GET.next|default:'/'|urlencode }}">✎</a>
|
<a class="btn btn-outline-secondary" href="{% url 'item_editor' item.id %}?next={{ request.GET.next|default:'/'|urlencode }}">✎</a>
|
||||||
|
|||||||
@@ -5,22 +5,29 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>my2dos</title>
|
<title>my2dos</title>
|
||||||
|
<meta name="theme-color" content="#212529">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="my2dos">
|
||||||
|
<link rel="manifest" href="{% static 'manifest.webmanifest' %}">
|
||||||
|
<link rel="icon" href="{% static 'icon.svg' %}" type="image/svg+xml">
|
||||||
|
<link rel="apple-touch-icon" href="{% static 'icon.svg' %}">
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
<script src="https://unpkg.com/htmx.org@1.9.12"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
<style>
|
<style>
|
||||||
body{background:#f7f7f4}.shell{max-width:960px}.item-card{border:0;border-radius:1rem}.content p:last-child{margin-bottom:0}.tag{font-size:.85rem}.slash-menu{position:absolute;z-index:20;top:100%;left:0;width:18rem}.done{opacity:.6}.done .content{text-decoration:line-through}.content img{max-width:100%;max-height:420px;border-radius:.5rem;border:1px solid #ddd;padding:.25rem;background:white}.overview-content{max-height:calc(var(--overview-lines) * 1.55em);overflow:hidden}.overview-content.expanded{max-height:none}.content{min-width:0;max-width:100%;overflow-wrap:anywhere}.content pre{position:relative;display:block;width:100%;max-width:100%;box-sizing:border-box;background:#1f2937;color:#f9fafb;border-radius:.6rem;padding:1rem;overflow-x:auto;overflow-y:auto;white-space:pre}.content pre code{display:block;color:inherit;white-space:pre;min-width:0}.copy-code{position:absolute;top:.4rem;right:.4rem;line-height:1}
|
body{background:#f7f7f4}.shell{max-width:960px}.item-card{border:0;border-radius:1rem}.content p:last-child{margin-bottom:0}.tag{font-size:.85rem}.slash-menu{position:absolute;z-index:20;top:100%;left:0;width:18rem}.done{opacity:.6}.done .content{text-decoration:line-through}.content img{max-width:100%;max-height:420px;border-radius:.5rem;border:1px solid #ddd;padding:.25rem;background:white}.overview-content{max-height:calc(var(--overview-lines) * 1.55em);overflow:hidden}.overview-content.expanded{max-height:none}.content{min-width:0;max-width:100%;overflow-wrap:anywhere}.content pre{position:relative;display:block;width:100%;max-width:100%;box-sizing:border-box;background:#1f2937;color:#f9fafb;border-radius:.6rem;padding:1rem;overflow-x:auto;overflow-y:auto;white-space:pre}.content pre code{display:block;color:inherit;white-space:pre;min-width:0}.copy-code{position:absolute;top:.4rem;right:.4rem;line-height:1}.mobile-create-buttons,.mobile-create-nav,.mobile-tags,.mobile-media,.mobile-tag-picker{display:none}.mobile-tag-popup{position:fixed;inset:auto .75rem 1rem .75rem;z-index:1050;max-height:55vh;overflow:auto}.mobile-tag-backdrop{position:fixed;inset:0;background:rgba(0,0,0,.25);z-index:1040}.camera-input{max-width:13rem}@media (max-width: 767.98px){.shell{max-width:100%}main.container{padding-left:.75rem;padding-right:.75rem}.navbar .container{gap:.5rem}.navbar-brand{font-size:1rem}.navbar .d-flex{gap:.5rem!important}.item-card{border-radius:.75rem}.mobile-create-buttons,.mobile-create-nav,.mobile-tags,.mobile-media{display:flex!important}.mobile-tag-picker{display:block!important}.desktop-quick-create{display:none!important}.mobile-create-nav{position:sticky;top:57px;z-index:10;background:#f7f7f4;padding:.35rem 0}.desktop-file{display:none!important}.mobile-create-buttons{position:sticky;top:57px;z-index:10;background:#f7f7f4;padding:.35rem 0}.quick-actions{flex-wrap:wrap}.quick-actions .form-control{min-width:100%}.slash-menu{width:calc(100vw - 1.5rem)}textarea.form-control-lg{font-size:1rem}.btn{touch-action:manipulation}.item-row{flex-direction:column}.item-actions{align-self:flex-end}.item-main{width:100%;min-width:0}}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="navbar bg-white border-bottom sticky-top"><div class="container shell"><a class="navbar-brand fw-bold" href="/">my2dos</a><div class="d-flex align-items-center gap-3"><a class="text-muted small d-none d-sm-inline text-decoration-none" 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>{% 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="/">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>{% 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>
|
||||||
<main class="container shell py-4">{% for message in messages %}<div class="alert alert-{{ message.tags|default:'info' }}">{{ message }}</div>{% endfor %}{% block content %}{% endblock %}</main>
|
<main class="container shell py-4">{% for message in messages %}<div class="alert alert-{{ message.tags|default:'info' }}">{{ message }}</div>{% endfor %}{% block content %}{% endblock %}</main>
|
||||||
<script>
|
<script>
|
||||||
document.body.addEventListener('htmx:configRequest', e => {e.detail.headers['X-CSRFToken']='{{ csrf_token }}'});
|
document.body.addEventListener('htmx:configRequest', e => {e.detail.headers['X-CSRFToken']='{{ csrf_token }}'});
|
||||||
window.quickComposer = window.quickComposer || function(tags, itemId=null){
|
window.quickComposer = window.quickComposer || function(tags, itemId=null){
|
||||||
return {
|
return {
|
||||||
open:false,text:'',active:0,query:'',currentEl:null,preview:null,pendingFiles:[],linkSuggestions:[],attachmentSuggestions:[],itemId:itemId,tags:tags||[],
|
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||[]})})},
|
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'}],
|
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'}]},
|
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'}]},
|
||||||
@@ -36,6 +43,7 @@ window.quickComposer = window.quickComposer || function(tags, itemId=null){
|
|||||||
window.searchBox = function(tags){return{open:false,active:0,q:'',tags:tags||[],get filtered(){let q=this.q.toLowerCase();return this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q))},onInput(e){this.q=e.target.value;this.open=this.q.startsWith('#')},onKeydown(e){if(!this.open)return;if(e.key==='ArrowDown'){e.preventDefault();this.active=(this.active+1)%Math.max(this.filtered.length,1)}else if(e.key==='ArrowUp'){e.preventDefault();this.active=(this.active-1+Math.max(this.filtered.length,1))%Math.max(this.filtered.length,1)}else if(e.key==='Enter'||e.key==='Tab'){if(this.filtered.length){e.preventDefault();this.select(this.filtered[this.active])}}else if(e.key==='Escape'){this.open=false}},select(tag){let url=new URL(window.location.href);url.searchParams.set('tag',tag);url.searchParams.delete('q');window.location.href=url.pathname+'?'+url.searchParams.toString()}}}
|
window.searchBox = function(tags){return{open:false,active:0,q:'',tags:tags||[],get filtered(){let q=this.q.toLowerCase();return this.tags.filter(t=>('#'+t).toLowerCase().startsWith(q))},onInput(e){this.q=e.target.value;this.open=this.q.startsWith('#')},onKeydown(e){if(!this.open)return;if(e.key==='ArrowDown'){e.preventDefault();this.active=(this.active+1)%Math.max(this.filtered.length,1)}else if(e.key==='ArrowUp'){e.preventDefault();this.active=(this.active-1+Math.max(this.filtered.length,1))%Math.max(this.filtered.length,1)}else if(e.key==='Enter'||e.key==='Tab'){if(this.filtered.length){e.preventDefault();this.select(this.filtered[this.active])}}else if(e.key==='Escape'){this.open=false}},select(tag){let url=new URL(window.location.href);url.searchParams.set('tag',tag);url.searchParams.delete('q');window.location.href=url.pathname+'?'+url.searchParams.toString()}}}
|
||||||
function enhanceCodeBlocks(root=document){root.querySelectorAll('.content pre:not([data-copy])').forEach(pre=>{pre.dataset.copy='1';let btn=document.createElement('button');btn.type='button';btn.className='copy-code btn btn-sm btn-light';btn.title='Kopieren';btn.innerHTML='⧉';btn.addEventListener('click',()=>navigator.clipboard.writeText(pre.querySelector('code')?.innerText || pre.innerText).then(()=>{btn.innerHTML='✓';setTimeout(()=>btn.innerHTML='⧉',1200)}));pre.appendChild(btn)})}
|
function enhanceCodeBlocks(root=document){root.querySelectorAll('.content pre:not([data-copy])').forEach(pre=>{pre.dataset.copy='1';let btn=document.createElement('button');btn.type='button';btn.className='copy-code btn btn-sm btn-light';btn.title='Kopieren';btn.innerHTML='⧉';btn.addEventListener('click',()=>navigator.clipboard.writeText(pre.querySelector('code')?.innerText || pre.innerText).then(()=>{btn.innerHTML='✓';setTimeout(()=>btn.innerHTML='⧉',1200)}));pre.appendChild(btn)})}
|
||||||
document.addEventListener('DOMContentLoaded',()=>enhanceCodeBlocks());document.body.addEventListener('htmx:afterSwap',e=>enhanceCodeBlocks(e.target));
|
document.addEventListener('DOMContentLoaded',()=>enhanceCodeBlocks());document.body.addEventListener('htmx:afterSwap',e=>enhanceCodeBlocks(e.target));
|
||||||
|
if('serviceWorker' in navigator){window.addEventListener('load',()=>navigator.serviceWorker.register('{% static "sw.js" %}').catch(()=>{}));}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
{% extends 'core/base.html' %}
|
{% extends 'core/base.html' %}
|
||||||
{% load querystring %}
|
{% load querystring %}
|
||||||
{% block content %}
|
{% 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-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">
|
<div class="row g-4">
|
||||||
<aside class="col-lg-3 order-lg-2">
|
<aside class="col-lg-3 order-lg-2">
|
||||||
<div class="card item-card shadow-sm"><div class="card-body">
|
<div class="card item-card shadow-sm"><div class="card-body">
|
||||||
@@ -31,14 +37,22 @@
|
|||||||
</div></div>
|
</div></div>
|
||||||
</aside>
|
</aside>
|
||||||
<section class="col-lg-9">
|
<section class="col-lg-9">
|
||||||
<form class="position-relative mb-4" method="post" enctype="multipart/form-data" hx-post="{{ request.get_full_path }}" hx-target="#items" hx-swap="afterbegin" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])" x-on:htmx:after-request="if($event.detail.successful){text='';open=false;$el.reset()}">
|
<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 %}
|
{% 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-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="insert('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></details>
|
||||||
{{ form.content }}
|
{{ form.content }}
|
||||||
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
|
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
|
||||||
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></template>
|
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></template>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-2 mt-2">
|
<div class="d-flex align-items-center gap-2 mt-2 quick-actions">
|
||||||
<input class="form-control" type="file" name="files" multiple x-ref="fileInput" @change="previewFile($event)">
|
<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>
|
<a class="btn btn-outline-secondary" href="{% url 'new_item' %}">Editor</a>
|
||||||
<button class="btn btn-dark px-4">Speichern</button>
|
<button class="btn btn-dark px-4">Speichern</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,13 @@
|
|||||||
<div class="card-body vstack gap-3">
|
<div class="card-body vstack gap-3">
|
||||||
<h1 class="h4">Eintrag #{{ item.id }} im Editor bearbeiten</h1>
|
<h1 class="h4">Eintrag #{{ item.id }} im Editor bearbeiten</h1>
|
||||||
<div class="small text-muted">Typ: {{ item.get_kind_display }}</div>
|
<div class="small text-muted">Typ: {{ 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>
|
||||||
|
<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="insert('#{{ tag.name|escapejs }}'); showTags=false">#{{ 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="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="Kommentar" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)">{{ item.comment }}</textarea>
|
||||||
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
|
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
|
||||||
|
|||||||
@@ -20,9 +20,16 @@
|
|||||||
<h1 class="h4 mb-3">Journal {{ selected_day }}</h1>
|
<h1 class="h4 mb-3">Journal {{ selected_day }}</h1>
|
||||||
<form class="position-relative mb-4" method="post" enctype="multipart/form-data" hx-post="{{ request.get_full_path }}" hx-target="#items" hx-swap="afterbegin" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])" x-on:htmx:after-request="if($event.detail.successful){text='';open=false;$el.reset()}">
|
<form class="position-relative mb-4" method="post" enctype="multipart/form-data" hx-post="{{ request.get_full_path }}" hx-target="#items" hx-swap="afterbegin" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])" x-on:htmx:after-request="if($event.detail.successful){text='';open=false;$el.reset()}">
|
||||||
{% csrf_token %}
|
{% 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-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="insert('#{{ tag.name|escapejs }}')">#{{ tag.name }}</button>{% endfor %}</div></details>
|
||||||
{{ form.content }}
|
{{ 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="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak><template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></template></div>
|
||||||
<div class="d-flex align-items-center gap-2 mt-2"><input class="form-control" type="file" name="files" multiple x-ref="fileInput" @change="previewFile($event)"><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">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>
|
||||||
</form>
|
</form>
|
||||||
<div id="items" class="vstack gap-3">
|
<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">Keine Journal-Aktivität an diesem Tag.</div>{% endfor %}
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
{% extends 'core/base.html' %}
|
{% extends 'core/base.html' %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<a class="btn btn-sm btn-outline-secondary mb-3" href="/">← zurück</a>
|
<a class="btn btn-sm btn-outline-secondary mb-3" href="{{ next_url }}">← zurück</a>
|
||||||
<form class="card item-card shadow-sm position-relative" method="post" enctype="multipart/form-data" x-data="quickComposer([{% for tag in tags %}'{{ tag.name|escapejs }}'{% if not forloop.last %},{% endif %}{% endfor %}])">
|
<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 %}
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="next" value="{{ next_url }}">
|
||||||
<div class="card-body vstack gap-3">
|
<div class="card-body vstack gap-3">
|
||||||
<h1 class="h4">Erweiterter Editor</h1>
|
<h1 class="h4">{% if requested_kind == 'todo' %}Aufgabe erstellen{% elif requested_kind == 'link' %}Link erstellen{% elif requested_kind == 'journal' %}Journal-Eintrag erstellen{% else %}Notiz erstellen{% endif %}</h1>
|
||||||
<textarea class="form-control" name="content" rows="16" placeholder="Längeres Dokument … /todo /link /journal /public #tag [[ ![[" x-model="text" @keydown="onKeydown($event)" @input="onInput($event)" @paste="handlePaste($event)"></textarea>
|
<div class="mobile-tag-picker">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" @click="showTags=true">Tags hinzufügen</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="insert('#{{ tag.name|escapejs }}'); showTags=false">#{{ 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>
|
||||||
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
|
<div class="slash-menu list-group shadow" x-show="open" @click.outside="open=false" x-cloak>
|
||||||
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></template>
|
<template x-for="(cmd, idx) in filtered" :key="cmd.token"><button type="button" class="list-group-item list-group-item-action" :class="{'active': idx === active}" @mouseenter="active=idx" @mousedown.prevent="insert(cmd.token)"><strong x-text="cmd.token"></strong> <span class="text-muted" x-text="cmd.label"></span></button></template>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="form-label">Bilder/Dateien</label>
|
<label class="form-label">Bild hinzufügen</label>
|
||||||
<input class="form-control" type="file" name="files" multiple x-ref="fileInput" @change="previewFile($event)">
|
<input class="form-control desktop-file" type="file" name="files" multiple accept="image/*,.pdf,.txt,.md" x-ref="fileInput" @change="previewFile($event)">
|
||||||
<div class="form-text">Mit <code>![[</code> können hochgeladene oder gepastete Bilder im Text eingefügt werden.</div>
|
<div 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>
|
</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>
|
<template x-if="preview"><div class="small"><span class="text-muted">Bildvorschau:</span><br><img :src="preview" class="img-thumbnail mt-1" style="max-height:180px"></div></template>
|
||||||
<button class="btn btn-dark align-self-start">Speichern</button>
|
<button class="btn btn-dark align-self-start">Speichern</button>
|
||||||
|
|||||||
+10
-3
@@ -162,15 +162,22 @@ def index(request):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def new_item(request):
|
def new_item(request):
|
||||||
|
requested_kind = request.GET.get('kind', '')
|
||||||
|
next_url = request.GET.get('next') or request.POST.get('next') or 'index'
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
form = QuickItemForm(request.POST, request.FILES)
|
form = QuickItemForm(request.POST, request.FILES)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
kind, visibility, content, url = parse_quick_content(form.cleaned_data['content'])
|
raw = form.cleaned_data['content']
|
||||||
|
if requested_kind in {'todo', 'link', 'journal'} and not re.search(r'/(todo|note|link|journal)\b', raw, re.I):
|
||||||
|
raw = f'/{requested_kind} {raw}'
|
||||||
|
kind, visibility, content, url = parse_quick_content(raw)
|
||||||
item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url)
|
item = Item.objects.create(owner=request.user, kind=kind, visibility=visibility, content=content, url=url)
|
||||||
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
|
attach_files_and_replace_tokens(item, request.FILES.getlist('files'))
|
||||||
item.sync_metadata()
|
item.sync_metadata()
|
||||||
return redirect(item.get_absolute_url())
|
return redirect(next_url)
|
||||||
return render(request, 'core/new_item.html', {'form': QuickItemForm(), 'tags': Tag.objects.all()})
|
return render(request, 'core/new_item.html', {
|
||||||
|
'form': QuickItemForm(), 'tags': Tag.objects.all(), 'requested_kind': requested_kind, 'next_url': next_url,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="110" fill="#212529"/>
|
||||||
|
<path d="M146 160h220M146 256h220M146 352h140" stroke="#fff" stroke-width="34" stroke-linecap="round"/>
|
||||||
|
<circle cx="104" cy="160" r="18" fill="#0dcaf0"/>
|
||||||
|
<circle cx="104" cy="256" r="18" fill="#ffc107"/>
|
||||||
|
<circle cx="104" cy="352" r="18" fill="#198754"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "my2dos",
|
||||||
|
"short_name": "my2dos",
|
||||||
|
"description": "Notizen, Aufgaben, Links und Journal",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f7f7f4",
|
||||||
|
"theme_color": "#212529",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
self.addEventListener('install', event => self.skipWaiting());
|
||||||
|
self.addEventListener('activate', event => event.waitUntil(self.clients.claim()));
|
||||||
|
|
||||||
|
// Minimaler Service Worker für Installierbarkeit als PWA.
|
||||||
|
// Offline-Funktionalität ist bewusst nicht implementiert.
|
||||||
|
self.addEventListener('fetch', () => {});
|
||||||
Reference in New Issue
Block a user