style(lists): live pill left of a divider, actions right, like the notes
Tasks get the live pill too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2b7356d043
commit
ae1f799d9c
4 files changed
+82
-46
No files matched your search
@@ -86,6 +86,12 @@ Komponente: [ListSwitcher.tsx](../frontend/src/components/ListSwitcher.tsx)
|
|||||||
- In der Kopfleiste am Handy (Aufgaben, Listen) ist der Titel der offenen Liste ein Knopf mit Pfeil. Ein Tipp klappt alle Listen auf, mit Icon, Zähler (`CountBadge`, `muted`) und Haken an der offenen; ein Tipp auf eine Liste wechselt dorthin.
|
- In der Kopfleiste am Handy (Aufgaben, Listen) ist der Titel der offenen Liste ein Knopf mit Pfeil. Ein Tipp klappt alle Listen auf, mit Icon, Zähler (`CountBadge`, `muted`) und Haken an der offenen; ein Tipp auf eine Liste wechselt dorthin.
|
||||||
- Schließt bei Tipp daneben und mit Escape. Am Desktop bleiben die Reiter über der Liste.
|
- Schließt bei Tipp daneben und mit Escape. Am Desktop bleiben die Reiter über der Liste.
|
||||||
|
|
||||||
|
## Kopfzeile: Status | Aktionen
|
||||||
|
|
||||||
|
Komponente für den Verbindungsstatus: [LivePill.tsx](../frontend/src/components/LivePill.tsx) (Notizen zeigen ihren eigenen Sync-Weg im selben Aussehen).
|
||||||
|
|
||||||
|
- Rechts im Kopf von Notizen, Aufgaben und Listen gilt dieselbe Reihenfolge: links die Statusanzeigen (Live-Pille, „Geteilt“), dann ein senkrechter Trenner (`w-px h-5 bg-border`), rechts die Knöpfe (Teilen, Ansicht, Menü). Am Handy genauso, dort steht in der Pille nur „Live“.
|
||||||
|
|
||||||
## Mikrofon in Eingabezeilen
|
## Mikrofon in Eingabezeilen
|
||||||
|
|
||||||
Komponente: [VoiceInputButton.tsx](../frontend/src/components/VoiceInputButton.tsx) (nutzt `useVoiceInput` wie der Quick Dump)
|
Komponente: [VoiceInputButton.tsx](../frontend/src/components/VoiceInputButton.tsx) (nutzt `useVoiceInput` wie der Quick Dump)
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { onRealtimeStatus, RealtimeTransport } from '../lib/api';
|
||||||
|
|
||||||
|
// Connection pill in the look of the notes' header (Doku/ui-patterns.md): whether changes of others
|
||||||
|
// arrive live, and how. Phones show the short label only.
|
||||||
|
export const LivePill: React.FC = () => {
|
||||||
|
const [status, setStatus] = useState<{ connected: boolean; transport: RealtimeTransport }>({
|
||||||
|
connected: false,
|
||||||
|
transport: 'none',
|
||||||
|
});
|
||||||
|
useEffect(() => onRealtimeStatus((connected, transport) => setStatus({ connected, transport })), []);
|
||||||
|
|
||||||
|
const { connected, transport } = status;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel text-muted flex-shrink-0"
|
||||||
|
title={
|
||||||
|
!connected
|
||||||
|
? 'Keine Live-Verbindung – Änderungen anderer erscheinen nach dem Neuladen'
|
||||||
|
: transport === 'sse'
|
||||||
|
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
|
||||||
|
: transport === 'poll'
|
||||||
|
? 'Live über Long-Polling (WebSocket und SSE werden blockiert)'
|
||||||
|
: 'Live-Verbindung über WebSocket'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-status-ok' : 'bg-status-warn'}`} />
|
||||||
|
{!connected ? (
|
||||||
|
'Offline'
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Live
|
||||||
|
{transport === 'poll' && <span className="hidden sm:inline"> (Long-Polling)</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,13 +10,14 @@ import {
|
|||||||
CloudOff,
|
CloudOff,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
|
import { ListItem, SharedList, Team, User, WSMessage } from '../../lib/types';
|
||||||
import { api, triggerHaptic, createRealtimeConnection, RealtimeTransport } from '../../lib/api';
|
import { api, triggerHaptic, createRealtimeConnection } from '../../lib/api';
|
||||||
import { readViewSession, writeViewSession } from '../../lib/viewSession';
|
import { readViewSession, writeViewSession } from '../../lib/viewSession';
|
||||||
import { OFFLINE_ITEM_PREFIX, pendingListItems } from '../../lib/offline';
|
import { OFFLINE_ITEM_PREFIX, pendingListItems } from '../../lib/offline';
|
||||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||||
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
import { AvatarStack, AvatarPerson } from '../../components/AvatarStack';
|
||||||
import { CountBadge } from '../../components/CountBadge';
|
import { CountBadge } from '../../components/CountBadge';
|
||||||
import { ListSwitcher } from '../../components/ListSwitcher';
|
import { ListSwitcher } from '../../components/ListSwitcher';
|
||||||
|
import { LivePill } from '../../components/LivePill';
|
||||||
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';
|
||||||
@@ -110,8 +111,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
const [editingQtyItemId, setEditingQtyItemId] = useState<string | null>(null);
|
const [editingQtyItemId, setEditingQtyItemId] = useState<string | null>(null);
|
||||||
const [editingQtyValue, setEditingQtyValue] = useState('');
|
const [editingQtyValue, setEditingQtyValue] = useState('');
|
||||||
|
|
||||||
const [wsConnected, setWsConnected] = useState(false);
|
|
||||||
const [realtimeTransport, setRealtimeTransport] = useState<RealtimeTransport>('none');
|
|
||||||
const [showNewListModal, setShowNewListModal] = useState(false);
|
const [showNewListModal, setShowNewListModal] = useState(false);
|
||||||
const [newListTitle, setNewListTitle] = useState('');
|
const [newListTitle, setNewListTitle] = useState('');
|
||||||
const [newListIcon, setNewListIcon] = useState('🛒');
|
const [newListIcon, setNewListIcon] = useState('🛒');
|
||||||
@@ -222,10 +221,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
|
|
||||||
const conn = createRealtimeConnection({
|
const conn = createRealtimeConnection({
|
||||||
listId: activeList.id,
|
listId: activeList.id,
|
||||||
onStatusChange: (status) => {
|
|
||||||
setWsConnected(status.connected);
|
|
||||||
setRealtimeTransport(status.transport);
|
|
||||||
},
|
|
||||||
onMessage: (msg: WSMessage) => {
|
onMessage: (msg: WSMessage) => {
|
||||||
const msgListId = msg.listId || (msg as any).list_id;
|
const msgListId = msg.listId || (msg as any).list_id;
|
||||||
if (msg.event === 'ITEM_CREATED' && msgListId) {
|
if (msg.event === 'ITEM_CREATED' && msgListId) {
|
||||||
@@ -1032,22 +1027,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</>
|
</>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
// Connection pill in the same look as the notes' header
|
|
||||||
const livePill = (
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1 h-7 px-2 rounded-btn text-[11px] bg-panel text-muted"
|
|
||||||
title={
|
|
||||||
!wsConnected
|
|
||||||
? 'Keine Live-Verbindung – Änderungen anderer erscheinen nach dem Neuladen'
|
|
||||||
: realtimeTransport === 'sse'
|
|
||||||
? 'Live-Verbindung über Server-Sent Events (WebSocket wird blockiert)'
|
|
||||||
: 'Live-Verbindung über WebSocket'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${wsConnected ? 'bg-status-ok' : 'bg-status-warn'}`} />
|
|
||||||
{wsConnected ? 'Live' : 'Offline'}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-3">
|
<div className="w-full space-y-3">
|
||||||
@@ -1117,8 +1096,9 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Header Section: Geteilt Badge, Icon-Only Share Button & Live Sync */}
|
{/* Right Header Section like the notes: status (live, shared) | actions */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<LivePill />
|
||||||
{activeList && (() => {
|
{activeList && (() => {
|
||||||
const isOwner = activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin');
|
const isOwner = activeList.isOwner !== false && (activeList.userId === currentUser?.id || !activeList.userId || currentUser?.role === 'admin');
|
||||||
const sharesCount = activeList.shares?.length || 0;
|
const sharesCount = activeList.shares?.length || 0;
|
||||||
@@ -1151,6 +1131,7 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<span className="w-px h-5 bg-border mx-0.5" aria-hidden />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openShareModal}
|
onClick={openShareModal}
|
||||||
@@ -1162,8 +1143,6 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{livePill}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1193,17 +1172,20 @@ export const SharedListsView: React.FC<SharedListsViewProps> = ({ onRefreshGloba
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
<LivePill />
|
||||||
{activeList && (
|
{activeList && (
|
||||||
<button
|
<>
|
||||||
type="button"
|
<span className="w-px h-5 bg-border" aria-hidden />
|
||||||
onClick={openShareModal}
|
<button
|
||||||
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center"
|
type="button"
|
||||||
title="Liste teilen"
|
onClick={openShareModal}
|
||||||
>
|
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center"
|
||||||
<Users className="w-4 h-4 text-accent" />
|
title="Liste teilen"
|
||||||
</button>
|
>
|
||||||
|
<Users className="w-4 h-4 text-accent" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{livePill}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 { ListSwitcher } from '../../components/ListSwitcher';
|
import { ListSwitcher } from '../../components/ListSwitcher';
|
||||||
|
import { LivePill } from '../../components/LivePill';
|
||||||
import { FormSheet } from '../../components/FormSheet';
|
import { FormSheet } from '../../components/FormSheet';
|
||||||
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
import { EmojiIconButton } from '../../components/EmojiIconButton';
|
||||||
import { useMobileActions } from '../../context/MobileActionContext';
|
import { useMobileActions } from '../../context/MobileActionContext';
|
||||||
@@ -1872,8 +1873,9 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Header Section: Sharing Badge, Icon-Only Button & View Switcher */}
|
{/* Right Header Section like the notes: status (live, shared) | actions (share, view switcher) */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<LivePill />
|
||||||
{currentSelectedList && (() => {
|
{currentSelectedList && (() => {
|
||||||
const listOwnerId = currentSelectedList.ownerId || currentSelectedList.userId;
|
const listOwnerId = currentSelectedList.ownerId || currentSelectedList.userId;
|
||||||
const isOwner = currentSelectedList.isOwner !== false && (!listOwnerId || listOwnerId === currentUser?.id);
|
const isOwner = currentSelectedList.isOwner !== false && (!listOwnerId || listOwnerId === currentUser?.id);
|
||||||
@@ -1910,6 +1912,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<span className="w-px h-5 bg-border mx-0.5" aria-hidden />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleOpenShareModal(currentSelectedList)}
|
onClick={() => handleOpenShareModal(currentSelectedList)}
|
||||||
@@ -1921,6 +1924,7 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
{!currentSelectedList && <span className="w-px h-5 bg-border mx-0.5" aria-hidden />}
|
||||||
|
|
||||||
{/* View Switcher: List vs Kanban */}
|
{/* View Switcher: List vs Kanban */}
|
||||||
<div className="flex border border-border rounded-btn bg-canvas overflow-hidden">
|
<div className="flex border border-border rounded-btn bg-canvas overflow-hidden">
|
||||||
@@ -1973,16 +1977,22 @@ export const TasksView: React.FC<TasksViewProps> = ({
|
|||||||
: `Papierkorb (${taskTrash.length})`}
|
: `Papierkorb (${taskTrash.length})`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{currentSelectedList && (
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
<button
|
<LivePill />
|
||||||
type="button"
|
{currentSelectedList && (
|
||||||
onClick={() => handleOpenShareModal(currentSelectedList)}
|
<>
|
||||||
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center flex-shrink-0"
|
<span className="w-px h-5 bg-border" aria-hidden />
|
||||||
title="Aufgabenliste teilen"
|
<button
|
||||||
>
|
type="button"
|
||||||
<Users className="w-4 h-4 text-accent" />
|
onClick={() => handleOpenShareModal(currentSelectedList)}
|
||||||
</button>
|
className="btn-icon min-h-[36px] min-w-[36px] flex items-center justify-center"
|
||||||
)}
|
title="Aufgabenliste teilen"
|
||||||
|
>
|
||||||
|
<Users className="w-4 h-4 text-accent" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isSelectedListReadOnly && (
|
{isSelectedListReadOnly && (
|
||||||
|
|||||||
Reference in new issue
Block a user