feat(legal): imprint & privacy notices, "Über MindSift"
- LEGAL_IMPRINT_/LEGAL_PRIVACY_ URL and Markdown (inline or *_FILE);
only http(s) URLs accepted
- /auth/status exposes legal info; public GET /legal/{imprint|privacy}
- Links on login/setup screen and in the settings footer: URL opens a new
tab, Markdown a reading modal; hidden when nothing is configured
- "Über MindSift" modal (version, license, repository) in settings and help
- Docs and compose/env examples
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a62b204e14
commit
d6c70d7a8e
17 files changed
+318
-2
No files matched your search
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { Brain, ExternalLink } from 'lucide-react';
|
||||
import { APP_VERSION } from '../version';
|
||||
import { ReadingModal } from './ReadingModal';
|
||||
|
||||
// Project facts shown in "Über MindSift"; change here when the repository or license changes
|
||||
export const PROJECT_REPO_URL = 'https://git.dsit.me/sandrodach/MindSift';
|
||||
export const PROJECT_LICENSE = 'Noch nicht festgelegt (keine LICENSE-Datei im Repository)';
|
||||
|
||||
export const AboutModal: React.FC<{ onClose: () => void }> = ({ onClose }) => (
|
||||
<ReadingModal title="Über MindSift" onClose={onClose}>
|
||||
<div className="space-y-4 text-xs text-muted leading-relaxed">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 bg-accent text-on-accent rounded-btn flex items-center justify-center shrink-0">
|
||||
<Brain className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-bold text-main">MindSift</p>
|
||||
<p>Der Kopf frei in drei Sekunden.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Die meisten Notiz- und Aufgaben-Apps verlangen Struktur im falschen Moment. MindSift trennt deshalb{' '}
|
||||
<strong className="text-main">Erfassen</strong> und <strong className="text-main">Organisieren</strong>: Gedanken
|
||||
landen sofort im Quick Dump und werden später mit einem Tastendruck zu Aufgabe, Notiz oder Listeneintrag. Alles läuft
|
||||
selbst gehostet in einem Container mit einer SQLite-Datei – deine Daten bleiben bei dir.
|
||||
</p>
|
||||
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5">
|
||||
<dt className="font-semibold text-main">Version</dt>
|
||||
<dd className="font-mono">{APP_VERSION}</dd>
|
||||
<dt className="font-semibold text-main">Lizenz</dt>
|
||||
<dd>{PROJECT_LICENSE}</dd>
|
||||
<dt className="font-semibold text-main">Quellcode</dt>
|
||||
<dd>
|
||||
<a href={PROJECT_REPO_URL} target="_blank" rel="noopener noreferrer" className="text-accent hover:underline inline-flex items-center gap-1">
|
||||
{PROJECT_REPO_URL.replace(/^https:\/\//, '')}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
<p className="text-[11px] text-faint">
|
||||
Impressum und Datenschutz dieser Instanz verantwortet ihr Betreiber; die Links stehen in den Kontoeinstellungen und auf der Anmeldeseite.
|
||||
</p>
|
||||
</div>
|
||||
</ReadingModal>
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
import React, { useState } from 'react';
|
||||
import { AuthStatus } from '../lib/types';
|
||||
import { api } from '../lib/api';
|
||||
import { MarkdownViewer } from '../lib/markdown';
|
||||
import { ReadingModal } from './ReadingModal';
|
||||
|
||||
type LegalType = 'imprint' | 'privacy';
|
||||
const TITLES: Record<LegalType, string> = { imprint: 'Impressum', privacy: 'Datenschutz' };
|
||||
|
||||
interface LegalLinksProps {
|
||||
legal?: AuthStatus['legal'];
|
||||
className?: string;
|
||||
children?: React.ReactNode; // further footer links, e.g. "Über MindSift"
|
||||
}
|
||||
|
||||
// Impressum / Datenschutz links: an external URL opens in a new tab, Markdown in a reading modal.
|
||||
// Links without URL or text are not shown at all.
|
||||
export const LegalLinks: React.FC<LegalLinksProps> = ({ legal, className = '', children }) => {
|
||||
const [open, setOpen] = useState<{ type: LegalType; content: string } | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const entries = (['imprint', 'privacy'] as LegalType[])
|
||||
.map((type) => ({
|
||||
type,
|
||||
url: type === 'imprint' ? legal?.imprintUrl : legal?.privacyUrl,
|
||||
hasContent: type === 'imprint' ? legal?.hasImprintContent : legal?.hasPrivacyContent,
|
||||
}))
|
||||
.filter((e) => e.url || e.hasContent);
|
||||
|
||||
if (entries.length === 0 && !children) return null;
|
||||
|
||||
const openText = async (type: LegalType) => {
|
||||
setError(null);
|
||||
try {
|
||||
setOpen({ type, content: await api.getLegalText(type) });
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Text konnte nicht geladen werden');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex flex-wrap items-center justify-center gap-x-4 gap-y-1 text-[11px] text-muted ${className}`}>
|
||||
{entries.map((e) =>
|
||||
e.url ? (
|
||||
<a key={e.type} href={e.url} target="_blank" rel="noopener noreferrer" className="hover:text-main underline-offset-2 hover:underline">
|
||||
{TITLES[e.type]}
|
||||
</a>
|
||||
) : (
|
||||
<button key={e.type} type="button" onClick={() => openText(e.type)} className="hover:text-main underline-offset-2 hover:underline">
|
||||
{TITLES[e.type]}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
{children}
|
||||
{error && <span className="text-status-err">{error}</span>}
|
||||
{open && (
|
||||
<ReadingModal title={TITLES[open.type]} onClose={() => setOpen(null)}>
|
||||
<MarkdownViewer content={open.content} />
|
||||
</ReadingModal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface ReadingModalProps {
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Slim modal for reading text (legal notices, about); Escape or backdrop click closes it
|
||||
export const ReadingModal: React.FC<ReadingModalProps> = ({ title, onClose, children }) => {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<div className="panel-surface w-full max-w-2xl max-h-[85vh] flex flex-col shadow-panel">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<h2 className="text-sm font-bold text-main">{title}</h2>
|
||||
<button type="button" onClick={onClose} className="btn-icon" title="Schließen">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 overflow-y-auto text-sm text-main">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user