Merge branch 'feat/footer-share-ci' into dev
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m39s

CI keeps the 'test once per code state' plan but records commit and
git tree as image labels instead of extra :sha-/:tree- tags, so the
registry only holds dev, latest, vX.Y.Z and stable; the plan test now
asserts the expected decisions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 21:29:56 +01:00
commit 141f87f3a0
22 files changed
+735 -61

No files matched your search

+16
View File
@@ -47,6 +47,22 @@
"purpose": "maskable"
}
],
"share_target": {
"action": "/share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "media",
"accept": ["image/*"]
}
]
}
},
"shortcuts": [
{
"name": "Gedanke sofort erfassen",
+73 -1
View File
@@ -5,10 +5,12 @@
// - Icons and manifest: from the cache, refreshed in the background.
// - API requests are never touched; offline data comes from the app's own cache (lib/offline.ts).
// Raise VERSION when this file changes in a way that needs fresh caches.
const VERSION = 'v2';
const VERSION = 'v3';
const SHELL_CACHE = `mindsift-shell-${VERSION}`;
const ASSET_CACHE = `mindsift-assets-${VERSION}`;
const MAX_ASSETS = 80;
const SHARE_DB_NAME = 'mindsift-share-target';
const SHARE_STORE_NAME = 'shared_items';
const SHELL_URLS = [
'/',
'/index.html',
@@ -80,8 +82,78 @@ async function staleWhileRevalidate(request) {
return hit || refresh;
}
function openShareDB() {
return new Promise((resolve, reject) => {
const req = indexedDB.open(SHARE_DB_NAME, 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) {
db.createObjectStore(SHARE_STORE_NAME, { autoIncrement: true, keyPath: 'id' });
}
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
}
async function savePendingShare(item) {
const db = await openShareDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(SHARE_STORE_NAME, 'readwrite');
tx.objectStore(SHARE_STORE_NAME).add(item);
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => {
db.close();
reject(tx.error);
};
tx.onabort = () => {
db.close();
reject(tx.error);
};
});
}
async function handleShareTarget(event) {
try {
const formData = await event.request.formData();
const title = formData.get('title');
const text = formData.get('text');
const url = formData.get('url');
const media = formData.get('media');
let fileName = '';
let hasMedia = false;
if (media && typeof media === 'object') {
fileName = media.name || 'image';
hasMedia = true;
}
await savePendingShare({
title: typeof title === 'string' ? title.trim() : '',
text: typeof text === 'string' ? text.trim() : '',
url: typeof url === 'string' ? url.trim() : '',
fileName,
hasMedia,
createdAt: Date.now(),
});
} catch (err) {
console.error('[SW] Share-Target Fehler:', err);
}
return Response.redirect('/?shared=pending', 303);
}
self.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method === 'POST') {
const url = new URL(request.url);
if (url.origin === self.location.origin && url.pathname === '/share-target') {
event.respondWith(handleShareTarget(event));
}
return;
}
if (request.method !== 'GET') return;
const url = new URL(request.url);
if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
+71 -2
View File
@@ -65,6 +65,9 @@ import { useMobileActions } from './context/MobileActionContext';
import { MobileHeaderMenu } from './components/MobileHeaderMenu';
import { UserAvatar } from './components/UserAvatar';
import { CommandPalette } from './components/CommandPalette';
import { AppFooter } from './components/AppFooter';
import { AboutModal } from './components/AboutModal';
import { consumePendingShares, formatSharedThought } from './lib/shareTarget';
import { APP_VERSION } from './version';
type ActiveTab =
@@ -170,6 +173,7 @@ export const App: React.FC = () => {
// Profile Dropdown State
const [showProfileMenu, setShowProfileMenu] = useState(false);
const [showAbout, setShowAbout] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
// Mobile Navigation & Quick-Action State
@@ -552,6 +556,46 @@ export const App: React.FC = () => {
);
}, [authStatus.demoMode, authStatus.demoResetInSeconds]);
// Web Share Target: import shared thoughts from external apps
useEffect(() => {
if (!user) return;
const params = new URLSearchParams(window.location.search);
const hasPendingShare = params.get('shared') === 'pending';
const processShares = async () => {
try {
const shares = await consumePendingShares();
if (shares.length === 0) return;
let importedCount = 0;
for (const share of shares) {
const content = formatSharedThought(share);
if (content) {
await api.captureThought(content, 'normal', 'manual', '', { target: 'inbox' });
importedCount++;
}
}
if (importedCount > 0) {
triggerHaptic(15);
setDemoNotice('Erfolgreich im Quick Dump abgelegt');
setTimeout(() => setDemoNotice(null), 4000);
refreshAll();
}
} catch (err) {
console.error('Fehler beim Import geteilter Daten:', err);
} finally {
if (hasPendingShare) {
const url = new URL(window.location.href);
url.searchParams.delete('shared');
window.history.replaceState({}, '', url.pathname + (url.search ? url.search : '') + url.hash);
}
}
};
processShares();
}, [user, refreshAll]);
// The demo database was reset: drop cached answers of the old data, remount the views (they load
// fresh data), refresh counters and the countdown. Sessions stay valid (fixed demo account IDs).
const handleDemoReset = useCallback(() => {
@@ -1119,8 +1163,8 @@ export const App: React.FC = () => {
{/* Main Container */}
{/* The workspace fills the window exactly and scrolls only inside the note, never the page */}
<main
className={`flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 sm:pb-6 ${
activeTab === 'workspace' ? 'min-h-0 flex flex-col' : ''
className={`flex-1 max-w-6xl w-full mx-auto px-3 sm:px-4 pt-3 pb-24 ${
activeTab === 'workspace' ? 'min-h-0 flex flex-col sm:pb-0' : 'sm:pb-6'
}`}
>
<ViewErrorBoundary onReset={() => setActiveTab('triage')}>
@@ -1202,6 +1246,11 @@ export const App: React.FC = () => {
</ViewErrorBoundary>
</main>
<AppFooter
legal={authStatus.legal}
onOpenAbout={() => setShowAbout(true)}
/>
{/* MOBILE BOTTOM NAVIGATION BAR (5 equal columns -> + FAB is dead-center) */}
<nav className="sm:hidden fixed bottom-0 left-0 right-0 z-40 bg-panel/95 backdrop-blur-md border-t border-border px-2 pt-1.5 pb-[calc(0.5rem+env(safe-area-inset-bottom))] grid grid-cols-5 items-center shadow-panel">
{/* Col 1: Triage */}
@@ -1594,6 +1643,19 @@ export const App: React.FC = () => {
<span>Hilfe & Anleitung</span>
</button>
<button
type="button"
onClick={() => {
triggerHaptic(10);
setShowMobileProfile(false);
setShowAbout(true);
}}
className="w-full px-3 py-2.5 rounded-btn text-left flex items-center gap-3 hover:bg-panel-elevated transition-colors text-xs cursor-pointer text-main"
>
<Brain className="w-4 h-4 text-accent" />
<span>Über MindSift</span>
</button>
{isServerAdmin && (
<button
type="button"
@@ -1626,6 +1688,11 @@ export const App: React.FC = () => {
<span>Abmelden</span>
</button>
</div>
{/* Version & About Hint on Mobile */}
<div className="pt-2 text-center text-[10px] text-faint font-mono">
MindSift {APP_VERSION}
</div>
</div>
</div>
)}
@@ -1725,6 +1792,8 @@ export const App: React.FC = () => {
onOpenTask={handleOpenTask}
onOpenList={handleOpenList}
/>
{showAbout && <AboutModal onClose={() => setShowAbout(false)} />}
</div>
);
};
+45
View File
@@ -0,0 +1,45 @@
import React from 'react';
import { AuthStatus } from '../lib/types';
import { APP_VERSION } from '../version';
import { LegalLinks } from './LegalLinks';
interface AppFooterProps {
legal?: AuthStatus['legal'];
onOpenAbout: () => void;
}
export const AppFooter: React.FC<AppFooterProps> = ({ legal, onOpenAbout }) => {
return (
<footer className="hidden sm:flex shrink-0 w-full max-w-6xl mx-auto px-4 py-2 items-center justify-between text-[11px] text-faint border-t border-border/40 select-none">
<div className="flex items-center gap-3">
<button
type="button"
onClick={onOpenAbout}
className="font-semibold text-muted hover:text-main transition-colors cursor-pointer"
>
MindSift
</button>
<button
type="button"
onClick={onOpenAbout}
className="font-mono hover:text-main transition-colors cursor-pointer"
title="Über MindSift & Versionsdetails"
>
{APP_VERSION}
</button>
<span className="text-border">·</span>
<button
type="button"
onClick={onOpenAbout}
className="hover:text-main transition-colors cursor-pointer underline-offset-2 hover:underline"
>
Über MindSift
</button>
</div>
<div className="flex items-center gap-3">
<LegalLinks legal={legal} className="!text-faint gap-3" />
</div>
</footer>
);
};
+13 -1
View File
@@ -28,7 +28,9 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
}))
.filter((e) => e.url || e.hasContent);
if (entries.length === 0 && !children) return null;
const customHref = legal?.customLinkUrl && safeHref(legal.customLinkUrl);
if (entries.length === 0 && !children && !customHref) return null;
const openText = async (type: LegalType) => {
setError(null);
@@ -52,6 +54,16 @@ export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', c
</button>
)
)}
{customHref && (
<a
href={customHref as string}
target="_blank"
rel="noopener noreferrer"
className="hover:text-main underline-offset-2 hover:underline"
>
{legal?.customLinkLabel || legal?.customLinkUrl}
</a>
)}
{children}
{error && <span className="text-status-err">{error}</span>}
{open && (
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { formatSharedThought } from '../shareTarget';
describe('formatSharedThought', () => {
it('formats pure text correctly', () => {
expect(formatSharedThought({ text: 'Spontaner Einfall' })).toBe('Spontaner Einfall');
});
it('formats pure url correctly', () => {
expect(formatSharedThought({ url: 'https://example.com/article' })).toBe('https://example.com/article');
});
it('combines title and url when text is empty', () => {
expect(formatSharedThought({ title: 'Toller Artikel', url: 'https://example.com/article' }))
.toBe('Toller Artikel\nhttps://example.com/article');
});
it('does not duplicate URL when text already contains it', () => {
const input = {
title: 'Artikel',
text: 'Lies das hier: https://example.com/article unbedingt!',
url: 'https://example.com/article',
};
expect(formatSharedThought(input))
.toBe('Artikel\nLies das hier: https://example.com/article unbedingt!');
});
it('does not duplicate URL when text is exactly the URL', () => {
const input = {
title: 'News',
text: 'https://news.example.com',
url: 'https://news.example.com',
};
expect(formatSharedThought(input))
.toBe('News\nhttps://news.example.com');
});
it('does not duplicate title if text starts with title', () => {
const input = {
title: 'MindSift Update',
text: 'MindSift Update ist jetzt verfügbar.',
};
expect(formatSharedThought(input))
.toBe('MindSift Update ist jetzt verfügbar.');
});
it('combines title, text and distinct URL', () => {
const input = {
title: 'Empfehlung',
text: 'Ein großartiges Tool für schnelles Erfassen.',
url: 'https://mindsift.app',
};
expect(formatSharedThought(input))
.toBe('Empfehlung\nEin großartiges Tool für schnelles Erfassen.\nhttps://mindsift.app');
});
it('handles image with text', () => {
const input = {
title: 'Foto vom Ausflug',
fileName: 'berge.jpg',
hasMedia: true,
};
expect(formatSharedThought(input))
.toBe('Foto vom Ausflug\n(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
});
it('handles image only with filename', () => {
const input = {
fileName: 'screenshot.png',
hasMedia: true,
};
expect(formatSharedThought(input))
.toBe('[screenshot.png] (Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
});
it('handles image only without filename', () => {
const input = {
hasMedia: true,
};
expect(formatSharedThought(input))
.toBe('(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)');
});
it('returns empty string if nothing is provided', () => {
expect(formatSharedThought({})).toBe('');
});
});
+117
View File
@@ -0,0 +1,117 @@
export interface SharedData {
id?: number;
title?: string;
text?: string;
url?: string;
fileName?: string;
hasMedia?: boolean;
createdAt?: number;
}
const SHARE_DB_NAME = 'mindsift-share-target';
const SHARE_STORE_NAME = 'shared_items';
/**
* Combines title, text, url and media note into a single thought string.
* Ensures URLs are not duplicated if already present in the shared text or title.
*/
export function formatSharedThought(share: SharedData): string {
const title = (share.title || '').trim();
const text = (share.text || '').trim();
const url = (share.url || '').trim();
const mediaNote = '(Bild geteilt – Bilder im Posteingang werden noch nicht unterstützt)';
let content = '';
// Check if url is already contained in text or title
const urlInText = url !== '' && text.includes(url);
const urlInTitle = url !== '' && title.includes(url);
// Combine title and text
if (title && text) {
if (text === title) {
content = text;
} else if (text.startsWith(title)) {
content = text;
} else {
content = `${title}\n${text}`;
}
} else if (title) {
content = title;
} else if (text) {
content = text;
}
// Append URL if not already included in text or title
if (url && !urlInText && !urlInTitle) {
if (content) {
content = `${content}\n${url}`;
} else {
content = url;
}
}
// Media note handling
if (share.hasMedia || share.fileName) {
if (content) {
content = `${content}\n${mediaNote}`;
} else if (share.fileName) {
content = `[${share.fileName}] ${mediaNote}`;
} else {
content = mediaNote;
}
}
return content.trim();
}
/**
* Retrieves and removes all pending shared items stored in IndexedDB by the Service Worker.
*/
export async function consumePendingShares(): Promise<SharedData[]> {
if (typeof indexedDB === 'undefined') return [];
return new Promise((resolve) => {
try {
const req = indexedDB.open(SHARE_DB_NAME, 1);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) {
db.createObjectStore(SHARE_STORE_NAME, { autoIncrement: true, keyPath: 'id' });
}
};
req.onsuccess = () => {
const db = req.result;
if (!db.objectStoreNames.contains(SHARE_STORE_NAME)) {
db.close();
return resolve([]);
}
const tx = db.transaction(SHARE_STORE_NAME, 'readwrite');
const store = tx.objectStore(SHARE_STORE_NAME);
const getAllReq = store.getAll();
getAllReq.onsuccess = () => {
const items: SharedData[] = getAllReq.result || [];
store.clear();
tx.oncomplete = () => {
db.close();
resolve(items);
};
tx.onerror = () => {
db.close();
resolve(items);
};
};
getAllReq.onerror = () => {
db.close();
resolve([]);
};
};
req.onerror = () => resolve([]);
} catch {
resolve([]);
}
});
}
+2
View File
@@ -20,6 +20,8 @@ export interface AuthStatus {
hasImprintContent?: boolean;
privacyUrl?: string;
hasPrivacyContent?: boolean;
customLinkLabel?: string;
customLinkUrl?: string;
};
// Voice input: own Whisper server, the browser's recognition, or switched off (STT_* environment)
voice?: { mode: VoiceMode; maxSeconds: number };