Merge dev into main (v0.39.0)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 28s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 28s
This commit is contained in:
commit
33c208b9a4
14 files changed
+144
-75
No files matched your search
+14
-1
@@ -6,6 +6,18 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.39.0] - 2026-11-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Icon beim Anlegen von Aufgabenlisten, Einkaufslisten und Notizen: Statt eines Textfelds öffnet ein Klick auf das Symbol denselben Emoji-Auswähler wie bei einer geöffneten Notiz (mit Suche und zuletzt verwendeten). Escape schließt nur den Auswähler, nicht das Fenster.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- „Als Notiz vertiefen“: Fälligkeit und Priorität stehen in der neuen Notiz direkt unter der Aufgabe, vor der Beschreibung und den Unteraufgaben statt ganz unten. Die Priorität steht auf Deutsch da („Wichtig“, „Dringend“, „Niedrig“).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Kanban: Beim Ablegen flackerte die Karte kurz, bevor sie an der neuen Stelle erschien. Jetzt nimmt sie direkt den Platz der Lücke ein.
|
||||||
|
- Beispiel-Konfiguration für den Whisper-Server (`docker-compose.yml`): Das Modell bleibt mit `WHISPER__TTL=-1` geladen; das bisher angegebene `STT_MODEL_TTL` kennt speaches 0.8.3 nicht, das Modell wurde deshalb nach 5 Minuten entladen.
|
||||||
|
|
||||||
## [0.38.1] - 2026-11-01
|
## [0.38.1] - 2026-11-01
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -647,7 +659,8 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
|
|||||||
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
- Geteilte Listen (Einkaufs-, Pack- und Checklisten) mit `#Kategorie`-Gruppierung, Mengenangaben und Echtzeit-Abhaken.
|
||||||
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
- Benutzer-, Gruppen- und Admin-Verwaltung (Ersteinrichtungs-Assistent, Einladungscodes, Profilbilder, persönliche API-Keys & Webhook-Ingest `/api/v1/webhooks/ingest`, JSON-Datenexport) sowie CSS-Theme-Engine mit 4 Themes (`Dark`, `Light`, `High Contrast`, `Soft`) und PWA-Unterstützung.
|
||||||
|
|
||||||
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.38.1...dev
|
[Unreleased]: https://git.dsit.me/sandrodach/MindSift/compare/v0.39.0...dev
|
||||||
|
[0.39.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.38.1...v0.39.0
|
||||||
[0.38.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.38.0...v0.38.1
|
[0.38.1]: https://git.dsit.me/sandrodach/MindSift/compare/v0.38.0...v0.38.1
|
||||||
[0.38.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.2...v0.38.0
|
[0.38.0]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.2...v0.38.0
|
||||||
[0.37.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.1...v0.37.2
|
[0.37.2]: https://git.dsit.me/sandrodach/MindSift/compare/v0.37.1...v0.37.2
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
[](#)
|
[](#)
|
||||||
[](#-schnellstart)
|
[](#-schnellstart)
|
||||||
[](#-deine-daten-gehören-dir)
|
[](#-deine-daten-gehören-dir)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
|
|
||||||
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
**MindSift** ist eine selbst gehostete Schaltstelle für spontane Gedanken, Aufgaben mit Kanban-Board, hierarchische Outliner-Notizen, die mehrere Personen gleichzeitig bearbeiten können, und gemeinsame Listen. Alles läuft als ein einzelner Container mit eingebetteter SQLite-Datenbank, auf deinem eigenen Server.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -79,6 +79,6 @@ services:
|
|||||||
# - ./whisper:/home/ubuntu/.cache/huggingface/hub
|
# - ./whisper:/home/ubuntu/.cache/huggingface/hub
|
||||||
# environment:
|
# environment:
|
||||||
# - WHISPER__COMPUTE_TYPE=int8 # schneller auf der CPU, kaum Qualitätsverlust
|
# - WHISPER__COMPUTE_TYPE=int8 # schneller auf der CPU, kaum Qualitätsverlust
|
||||||
# - STT_MODEL_TTL=-1 # Modell im Speicher halten statt nach 5 Min. zu entladen
|
# - WHISPER__TTL=-1 # Modell im Speicher halten statt nach 5 Min. zu entladen
|
||||||
# - ENABLE_UI=false # ohne die Weboberfläche von speaches
|
# - ENABLE_UI=false # ohne die Weboberfläche von speaches
|
||||||
# - LOG_LEVEL=info
|
# - LOG_LEVEL=info
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.38.1",
|
"version": "0.39.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"version": "0.38.1",
|
"version": "0.39.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/inter": "5.3.0",
|
"@fontsource-variable/inter": "5.3.0",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "mindsift-frontend",
|
"name": "mindsift-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.38.1",
|
"version": "0.39.0",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { EmojiPickerModal } from '../features/workspace/EmojiPickerModal';
|
||||||
|
|
||||||
|
interface EmojiIconButtonProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (emoji: string) => void;
|
||||||
|
// Default icon; removing the icon in the picker goes back to it
|
||||||
|
fallback: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Icon field of the create dialogs (note, task list, list): the emoji as a button that opens the same
|
||||||
|
// picker as the icon of an open note (Doku/ui-patterns.md).
|
||||||
|
export const EmojiIconButton: React.FC<EmojiIconButtonProps> = ({ value, onChange, fallback }) => {
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [anchor, setAnchor] = useState<DOMRect | null>(null);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAnchor((cur) => (cur ? null : buttonRef.current?.getBoundingClientRect() ?? null))}
|
||||||
|
title="Icon auswählen"
|
||||||
|
aria-label="Icon auswählen"
|
||||||
|
className="w-12 sm:w-10 h-11 sm:h-8 flex-shrink-0 flex items-center justify-center input-field text-lg sm:text-base leading-none cursor-pointer hover:border-strong"
|
||||||
|
>
|
||||||
|
{value || fallback}
|
||||||
|
</button>
|
||||||
|
{anchor && (
|
||||||
|
<EmojiPickerModal
|
||||||
|
currentIcon={value}
|
||||||
|
anchor={anchor}
|
||||||
|
onSelectEmoji={(emoji) => {
|
||||||
|
onChange(emoji || fallback);
|
||||||
|
setAnchor(null);
|
||||||
|
}}
|
||||||
|
onClose={() => setAnchor(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -19,6 +19,7 @@ import { CountBadge } from '../../components/CountBadge';
|
|||||||
import { OriginBadge } from '../../components/OriginBadge';
|
import { OriginBadge } from '../../components/OriginBadge';
|
||||||
import { VoiceInputButton } from '../../components/VoiceInputButton';
|
import { VoiceInputButton } from '../../components/VoiceInputButton';
|
||||||
import { FormSheet } from '../../components/FormSheet';
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
|
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
|
|
||||||
interface SharedListsViewProps {
|
interface SharedListsViewProps {
|
||||||
@@ -1203,14 +1204,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<input
|
<EmojiIconButton value={newListIcon} onChange={setNewListIcon} fallback="🛒" />
|
||||||
type="text"
|
|
||||||
value={newListIcon}
|
|
||||||
onChange={(e) => setNewListIcon(e.target.value)}
|
|
||||||
maxLength={2}
|
|
||||||
className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm"
|
|
||||||
title="Icon (Emoji)"
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newListTitle}
|
value={newListTitle}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { ManageColumnsModal, DEFAULT_KANBAN_COLUMNS, columnDotClass } from './Ma
|
|||||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
import { FormSheet } from '../../components/FormSheet';
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
|
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
|
|
||||||
// Drag & drop needs a mouse or pen; on touch-only devices a draggable row can swallow a slightly
|
// Drag & drop needs a mouse or pen; on touch-only devices a draggable row can swallow a slightly
|
||||||
@@ -924,9 +925,15 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
const renderDropSlot = (colId: string, index: number) => (
|
const renderDropSlot = (colId: string, index: number) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={`slot-${colId}-${index}`}
|
key={`slot-${colId}-${index}`}
|
||||||
initial={{ height: 0, opacity: 0 }}
|
variants={{
|
||||||
animate={{ height: dragHeightRef.current, opacity: 1 }}
|
closed: { height: 0, opacity: 0 },
|
||||||
exit={{ height: 0, opacity: 0 }}
|
open: { height: dragHeightRef.current, opacity: 1 },
|
||||||
|
// On a drop the card takes the gap's place at once, so the gap goes without closing first
|
||||||
|
gone: (dropped: boolean) => ({ height: 0, opacity: 0, transition: { duration: dropped ? 0 : 0.2 } }),
|
||||||
|
}}
|
||||||
|
initial="closed"
|
||||||
|
animate="open"
|
||||||
|
exit="gone"
|
||||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||||
className="rounded-card border-2 border-dashed border-accent/50 bg-accent/5 pointer-events-none"
|
className="rounded-card border-2 border-dashed border-accent/50 bg-accent/5 pointer-events-none"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@@ -967,6 +974,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
const slot = kanbanSlot;
|
const slot = kanbanSlot;
|
||||||
resetKanbanDrag();
|
resetKanbanDrag();
|
||||||
if (!sourceId) return;
|
if (!sourceId) return;
|
||||||
|
// Set with the move in one render: the card shows up marked in its new place, not a moment later
|
||||||
|
setJustDroppedId(sourceId);
|
||||||
|
window.setTimeout(() => setJustDroppedId((cur) => (cur === sourceId ? null : cur)), 900);
|
||||||
const others = colTasks.filter((t) => t.id !== sourceId);
|
const others = colTasks.filter((t) => t.id !== sourceId);
|
||||||
if (others.length === 0 || !slot || slot.col !== colId) {
|
if (others.length === 0 || !slot || slot.col !== colId) {
|
||||||
await moveTaskToColumn(sourceId, colId);
|
await moveTaskToColumn(sourceId, colId);
|
||||||
@@ -975,8 +985,6 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
await moveTaskNextTo(sourceId, others[others.length - 1], 'after', colId);
|
await moveTaskNextTo(sourceId, others[others.length - 1], 'after', colId);
|
||||||
}
|
}
|
||||||
setJustDroppedId(sourceId);
|
|
||||||
window.setTimeout(() => setJustDroppedId((cur) => (cur === sourceId ? null : cur)), 900);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// A drag that ends outside any column (Escape, dropped elsewhere) leaves nothing behind
|
// A drag that ends outside any column (Escape, dropped elsewhere) leaves nothing behind
|
||||||
@@ -2285,14 +2293,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
<label className="block text-xs text-muted mb-1">Titel der Liste</label>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<input
|
<EmojiIconButton value={newListIcon} onChange={setNewListIcon} fallback="📋" />
|
||||||
type="text"
|
|
||||||
value={newListIcon}
|
|
||||||
onChange={(e) => setNewListIcon(e.target.value)}
|
|
||||||
maxLength={2}
|
|
||||||
className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm"
|
|
||||||
title="Icon (Emoji)"
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={newListTitle}
|
value={newListTitle}
|
||||||
@@ -2458,13 +2459,14 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={task.id}>
|
<React.Fragment key={task.id}>
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false} custom={justDroppedId !== null}>
|
||||||
{slotHere >= 0 && slotHere === visibleIdx && renderDropSlot(col.id, visibleIdx)}
|
{slotHere >= 0 && slotHere === visibleIdx && renderDropSlot(col.id, visibleIdx)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
<div
|
<div
|
||||||
draggable={canDragTasks && !readOnly}
|
draggable={canDragTasks && !readOnly}
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
handleTaskDragStart(e, task);
|
handleTaskDragStart(e, task);
|
||||||
|
setJustDroppedId(null);
|
||||||
dragHeightRef.current = e.currentTarget.getBoundingClientRect().height;
|
dragHeightRef.current = e.currentTarget.getBoundingClientRect().height;
|
||||||
// Hidden a tick later: hiding the source right away would cancel the drag
|
// Hidden a tick later: hiding the source right away would cancel the drag
|
||||||
window.setTimeout(() => setKanbanHiddenId(task.id), 0);
|
window.setTimeout(() => setKanbanHiddenId(task.id), 0);
|
||||||
@@ -2481,7 +2483,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'
|
readOnly ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing'
|
||||||
} ${
|
} ${
|
||||||
kanbanHiddenId === task.id ? 'hidden' : isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
|
kanbanHiddenId === task.id ? 'hidden' : isDragging ? 'opacity-30 border border-dashed border-accent/40' : ''
|
||||||
} ${justDroppedId === task.id ? 'ring-2 ring-accent/60 animate-in fade-in zoom-in-95 duration-300' : ''}`}
|
} ${justDroppedId === task.id ? 'ring-2 ring-accent/60' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<p
|
<p
|
||||||
@@ -2629,7 +2631,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<AnimatePresence initial={false}>
|
<AnimatePresence initial={false} custom={justDroppedId !== null}>
|
||||||
{slotHere >= 0 && slotHere === visibleIds.length && renderDropSlot(col.id, visibleIds.length)}
|
{slotHere >= 0 && slotHere === visibleIds.length && renderDropSlot(col.id, visibleIds.length)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { Suspense, useEffect, useRef, useState } from 'react';
|
import React, { Suspense, useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { X, Trash2, Loader2 } from 'lucide-react';
|
import { X, Trash2, Loader2 } from 'lucide-react';
|
||||||
import type { EmojiClickData } from 'emoji-picker-react';
|
import type { EmojiClickData } from 'emoji-picker-react';
|
||||||
import { Categories, EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react';
|
import { Categories, EmojiStyle, Theme, SuggestionMode } from 'emoji-picker-react';
|
||||||
@@ -25,12 +26,16 @@ interface EmojiPickerModalProps {
|
|||||||
currentIcon?: string;
|
currentIcon?: string;
|
||||||
onSelectEmoji: (emoji: string) => void;
|
onSelectEmoji: (emoji: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
// Inside a dialog (which clips and transforms its content): the picker is put on the page instead and
|
||||||
|
// opens below this button on desktop
|
||||||
|
anchor?: DOMRect | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
||||||
currentIcon,
|
currentIcon,
|
||||||
onSelectEmoji,
|
onSelectEmoji,
|
||||||
onClose,
|
onClose,
|
||||||
|
anchor,
|
||||||
}) => {
|
}) => {
|
||||||
const { inset, visibleHeight } = useKeyboardInset();
|
const { inset, visibleHeight } = useKeyboardInset();
|
||||||
const isPhone = typeof window !== 'undefined' && window.innerWidth < 640;
|
const isPhone = typeof window !== 'undefined' && window.innerWidth < 640;
|
||||||
@@ -61,6 +66,7 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
e.stopPropagation(); // only the picker closes, not the dialog it was opened from
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -74,10 +80,10 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown, true);
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', handleKeyDown);
|
window.removeEventListener('keydown', handleKeyDown, true);
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
};
|
};
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
@@ -90,11 +96,18 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
handlePick(emojiData.emoji);
|
handlePick(emojiData.emoji);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const anchorStyle = anchor
|
||||||
|
? ({
|
||||||
|
'--pick-left': `${Math.max(12, Math.min(anchor.left, window.innerWidth - 372))}px`,
|
||||||
|
'--pick-top': `${Math.min(anchor.bottom + 4, window.innerHeight - 532)}px`,
|
||||||
|
} as React.CSSProperties)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const picker = (
|
||||||
<>
|
<>
|
||||||
{/* Mobile Backdrop (< 640px) */}
|
{/* Mobile Backdrop (< 640px) */}
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/60 z-40 sm:hidden backdrop-blur-xs"
|
className={`fixed inset-0 bg-black/60 sm:hidden backdrop-blur-xs ${anchor ? 'z-[60]' : 'z-40'}`}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
@@ -102,12 +115,17 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
{/* Main Container: Mobile Bottom-Sheet (<640px) & Desktop Popover (>=640px) */}
|
{/* Main Container: Mobile Bottom-Sheet (<640px) & Desktop Popover (>=640px) */}
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="
|
className={`
|
||||||
fixed inset-x-0 bottom-[var(--kb-inset)] z-50 rounded-t-2xl max-h-[var(--vv-max)] bg-panel border-t border-border shadow-2xl flex flex-col p-3 space-y-3
|
fixed inset-x-0 bottom-[var(--kb-inset)] rounded-t-2xl max-h-[var(--vv-max)] bg-panel border-t border-border shadow-2xl flex flex-col p-3 space-y-3
|
||||||
sm:absolute sm:inset-auto sm:left-0 sm:top-full sm:mt-1 sm:z-40 sm:w-[360px] sm:max-h-[520px] sm:rounded-card sm:border sm:border-border sm:shadow-xl
|
sm:inset-auto sm:w-[360px] sm:max-h-[520px] sm:rounded-card sm:border sm:border-border sm:shadow-xl ${
|
||||||
"
|
anchor
|
||||||
|
? 'z-[61] sm:fixed sm:left-[var(--pick-left)] sm:top-[max(12px,var(--pick-top))]'
|
||||||
|
: 'z-50 sm:absolute sm:left-0 sm:top-full sm:mt-1 sm:z-40'
|
||||||
|
}
|
||||||
|
`}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
|
...anchorStyle,
|
||||||
// Inject MindSift CSS variables into emoji-picker-react for seamless theming
|
// Inject MindSift CSS variables into emoji-picker-react for seamless theming
|
||||||
'--epr-bg-color': 'var(--bg-panel)',
|
'--epr-bg-color': 'var(--bg-panel)',
|
||||||
'--epr-category-label-bg-color': 'var(--bg-panel)',
|
'--epr-category-label-bg-color': 'var(--bg-panel)',
|
||||||
@@ -193,4 +211,5 @@ export const EmojiPickerModal: React.FC<EmojiPickerModalProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
return anchor ? createPortal(picker, document.body) : picker;
|
||||||
};
|
};
|
||||||
@@ -57,6 +57,7 @@ import { JournalCalendar } from './JournalCalendar';
|
|||||||
import { isJournalEnabled, readWorkspaceSession, todayISO, writeWorkspaceSession } from '../../lib/journal';
|
import { isJournalEnabled, readWorkspaceSession, todayISO, writeWorkspaceSession } from '../../lib/journal';
|
||||||
import { MergeDialog } from './MergeDialog';
|
import { MergeDialog } from './MergeDialog';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
|
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
||||||
import { extractHashtags } from '../../lib/hashtags';
|
import { extractHashtags } from '../../lib/hashtags';
|
||||||
|
|
||||||
interface WorkspaceViewProps {
|
interface WorkspaceViewProps {
|
||||||
@@ -2968,14 +2969,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<input
|
<EmojiIconButton value={newNoteIcon} onChange={setNewNoteIcon} fallback="📝" />
|
||||||
type="text"
|
|
||||||
value={newNoteIcon}
|
|
||||||
onChange={(e) => setNewNoteIcon(e.target.value)}
|
|
||||||
aria-label="Emoji"
|
|
||||||
title="Icon (Emoji)"
|
|
||||||
className="w-12 sm:w-10 h-11 sm:h-8 text-center input-field text-base sm:text-sm"
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
|||||||
+2
-14
@@ -25,19 +25,7 @@ const (
|
|||||||
mailFooterAccount = "Diese E-Mail betrifft dein MindSift-Konto und kommt deshalb unabhängig von deinen Benachrichtigungen."
|
mailFooterAccount = "Diese E-Mail betrifft dein MindSift-Konto und kommt deshalb unabhängig von deinen Benachrichtigungen."
|
||||||
)
|
)
|
||||||
|
|
||||||
// priorityLabel / priorityTone: how a task's priority reads and is coloured in mails
|
// priorityTone: how a task's priority is coloured in mails (domain.PriorityLabel gives the text)
|
||||||
func priorityLabel(p string) string {
|
|
||||||
switch p {
|
|
||||||
case "urgent":
|
|
||||||
return "Dringend"
|
|
||||||
case "high":
|
|
||||||
return "Wichtig"
|
|
||||||
case "low":
|
|
||||||
return "Niedrig"
|
|
||||||
}
|
|
||||||
return "Normal"
|
|
||||||
}
|
|
||||||
|
|
||||||
func priorityTone(p string) string {
|
func priorityTone(p string) string {
|
||||||
switch p {
|
switch p {
|
||||||
case "urgent":
|
case "urgent":
|
||||||
@@ -52,7 +40,7 @@ func priorityTone(p string) string {
|
|||||||
func (s *Server) dueAlarmMail(a storage.DueTaskAlarm, due string) mailContent {
|
func (s *Server) dueAlarmMail(a storage.DueTaskAlarm, due string) mailContent {
|
||||||
meta := "Fällig: " + due
|
meta := "Fällig: " + due
|
||||||
if a.Priority != "" && a.Priority != "normal" {
|
if a.Priority != "" && a.Priority != "normal" {
|
||||||
meta += " · " + priorityLabel(a.Priority)
|
meta += " · " + domain.PriorityLabel(a.Priority)
|
||||||
}
|
}
|
||||||
c := mailContent{
|
c := mailContent{
|
||||||
Preheader: "Fällig: " + a.Title,
|
Preheader: "Fällig: " + a.Title,
|
||||||
|
|||||||
@@ -75,3 +75,16 @@ func HumanDue(raw string) string {
|
|||||||
}
|
}
|
||||||
return t.Format("02.01.2006")
|
return t.Format("02.01.2006")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PriorityLabel: how a task's priority reads in mails and notes.
|
||||||
|
func PriorityLabel(p string) string {
|
||||||
|
switch p {
|
||||||
|
case "urgent":
|
||||||
|
return "Dringend"
|
||||||
|
case "high":
|
||||||
|
return "Wichtig"
|
||||||
|
case "low":
|
||||||
|
return "Niedrig"
|
||||||
|
}
|
||||||
|
return "Normal"
|
||||||
|
}
|
||||||
@@ -37,6 +37,10 @@ func TestDeepenTaskIntoNoteKeepsTheTask(t *testing.T) {
|
|||||||
if err != nil || doc == nil || len(doc.Blocks) == 0 || doc.Blocks[0].ID != deepened.BlockID || doc.Blocks[0].Type != "todo" {
|
if err != nil || doc == nil || len(doc.Blocks) == 0 || doc.Blocks[0].ID != deepened.BlockID || doc.Blocks[0].Type != "todo" {
|
||||||
t.Fatalf("first block should be the linked checkbox: %+v %v", doc, err)
|
t.Fatalf("first block should be the linked checkbox: %+v %v", doc, err)
|
||||||
}
|
}
|
||||||
|
// Due date and priority come right under the checkbox, before the description
|
||||||
|
if len(doc.Blocks) < 3 || doc.Blocks[1].Content != "Priorität: Wichtig" || doc.Blocks[2].Content != "Restic oder Borg?" {
|
||||||
|
t.Fatalf("priority line should follow the checkbox, then the description: %+v", doc.Blocks)
|
||||||
|
}
|
||||||
// Completing the task ticks the checkbox in the note
|
// Completing the task ticks the checkbox in the note
|
||||||
if store.SyncDocBlockAndTask(anna.ID, th.ID, true) != deepened.DocID {
|
if store.SyncDocBlockAndTask(anna.ID, th.ID, true) != deepened.DocID {
|
||||||
t.Fatal("completing the task should tick its block")
|
t.Fatal("completing the task should tick its block")
|
||||||
|
|||||||
@@ -335,6 +335,22 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 17a. Die anderen Details aus der Aufgabe sollen natürlich ebenfalls übernommen werden (Notizen, Subtasks, Fälligkeit/Priorität).
|
// 17a. Die anderen Details aus der Aufgabe sollen natürlich ebenfalls übernommen werden (Notizen, Subtasks, Fälligkeit/Priorität).
|
||||||
|
// Due date and priority right under the checkbox, above the description and the subtasks
|
||||||
|
var metaItems []string
|
||||||
|
if thought.DueDate != "" {
|
||||||
|
metaItems = append(metaItems, "Fällig: "+domain.HumanDue(thought.DueDate))
|
||||||
|
}
|
||||||
|
if thought.Priority != "" && thought.Priority != "normal" {
|
||||||
|
metaItems = append(metaItems, "Priorität: "+domain.PriorityLabel(thought.Priority))
|
||||||
|
}
|
||||||
|
if len(metaItems) > 0 {
|
||||||
|
blocks = append(blocks, domain.DocumentBlock{
|
||||||
|
ID: domain.NewID("blk"),
|
||||||
|
Type: "bullet",
|
||||||
|
Content: strings.Join(metaItems, " • "),
|
||||||
|
Level: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
if strings.TrimSpace(thought.Notes) != "" {
|
if strings.TrimSpace(thought.Notes) != "" {
|
||||||
blocks = append(blocks, domain.DocumentBlock{
|
blocks = append(blocks, domain.DocumentBlock{
|
||||||
ID: domain.NewID("blk"),
|
ID: domain.NewID("blk"),
|
||||||
@@ -359,21 +375,6 @@ func (s *Store) UpdateThoughtTriage(userID, thoughtID, action, convertTo, target
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var metaItems []string
|
|
||||||
if thought.DueDate != "" {
|
|
||||||
metaItems = append(metaItems, "Fällig: "+domain.HumanDue(thought.DueDate))
|
|
||||||
}
|
|
||||||
if thought.Priority != "" && thought.Priority != "normal" {
|
|
||||||
metaItems = append(metaItems, "Priorität: "+thought.Priority)
|
|
||||||
}
|
|
||||||
if len(metaItems) > 0 {
|
|
||||||
blocks = append(blocks, domain.DocumentBlock{
|
|
||||||
ID: domain.NewID("blk"),
|
|
||||||
Type: "bullet",
|
|
||||||
Content: strings.Join(metaItems, " • "),
|
|
||||||
Level: 1,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if len(blocks) == 1 {
|
if len(blocks) == 1 {
|
||||||
blocks = append(blocks, domain.DocumentBlock{
|
blocks = append(blocks, domain.DocumentBlock{
|
||||||
ID: domain.NewID("blk"),
|
ID: domain.NewID("blk"),
|
||||||
|
|||||||
Reference in new issue
Block a user