Archiv: Geteilt-von-mir Tab + 3D-Datei Downloads für Owner und Empfänger

This commit is contained in:
2026-05-28 13:39:33 +02:00
parent 83e88745fa
commit 2da72f1246
2 changed files with 167 additions and 25 deletions

View File

@@ -31,12 +31,81 @@ router.get('/', authenticate, (req, res) => {
ORDER BY c.created_at DESC ORDER BY c.created_at DESC
`).all(uid); `).all(uid);
// Geteilt von mir
const sharedByMe = db.prepare(`
SELECT c.*, u.username as shared_with_name, cs.created_at as shared_at
FROM calculations c
JOIN calculation_shares cs ON cs.calc_id = c.id
JOIN users u ON u.id = cs.shared_with
WHERE c.user_id = ?
ORDER BY c.name ASC
`).all(uid);
// has_files auch für shared items prüfen (anhand owner's Ordner)
const sharedWithFiles = shared.map(i => {
const ownerBase = db.prepare(
"SELECT id FROM folders WHERE user_id=? AND name='3D-Modelle' AND parent_id IS NULL"
).get(i.user_id);
let has_files = false;
if (ownerBase) {
const sub = db.prepare(
'SELECT id FROM folders WHERE user_id=? AND name=? AND parent_id=?'
).get(i.user_id, i.name, ownerBase.id);
if (sub) {
const cnt = db.prepare('SELECT COUNT(*) AS c FROM files WHERE folder_id=?').get(sub.id);
has_files = cnt.c > 0;
}
}
return { ...i, has_files };
});
res.json({ res.json({
own: items.map(i => ({ ...i, has_files: !!filesMap[i.name] })), own: items.map(i => ({ ...i, has_files: !!filesMap[i.name] })),
shared: shared, shared: sharedWithFiles,
sharedByMe: sharedByMe.map(i => ({ ...i, has_files: !!filesMap[i.name] })),
}); });
}); });
// Dateien eines Modells abrufen (für Owner + shared users)
router.get('/:id/files', authenticate, (req, res) => {
const uid = req.user.id;
const calc = db.prepare('SELECT * FROM calculations WHERE id=?').get(req.params.id);
if (!calc) return res.status(404).json({ error: 'Nicht gefunden' });
const isOwner = calc.user_id === uid;
const isShared = db.prepare('SELECT id FROM calculation_shares WHERE calc_id=? AND shared_with=?').get(calc.id, uid);
if (!isOwner && !isShared) return res.status(403).json({ error: 'Kein Zugriff' });
const baseFolder = db.prepare(
"SELECT id FROM folders WHERE user_id=? AND name='3D-Modelle' AND parent_id IS NULL"
).get(calc.user_id);
if (!baseFolder) return res.json([]);
const sub = db.prepare('SELECT id FROM folders WHERE user_id=? AND name=? AND parent_id=?')
.get(calc.user_id, calc.name, baseFolder.id);
if (!sub) return res.json([]);
res.json(db.prepare('SELECT * FROM files WHERE folder_id=? ORDER BY created_at ASC').all(sub.id));
});
// Einzelne Datei herunterladen (Owner + shared users)
router.get('/:id/files/:fileId/download', authenticate, (req, res) => {
const uid = req.user.id;
const calc = db.prepare('SELECT * FROM calculations WHERE id=?').get(req.params.id);
if (!calc) return res.status(404).json({ error: 'Nicht gefunden' });
const isOwner = calc.user_id === uid;
const isShared = db.prepare('SELECT id FROM calculation_shares WHERE calc_id=? AND shared_with=?').get(calc.id, uid);
if (!isOwner && !isShared) return res.status(403).json({ error: 'Kein Zugriff' });
const file = db.prepare('SELECT * FROM files WHERE id=?').get(req.params.fileId);
if (!file) return res.status(404).json({ error: 'Datei nicht gefunden' });
const path = require('path');
const fs = require('fs');
const UPLOAD_DIR = process.env.UPLOAD_DIR || '/data/uploads';
const fp = path.join(UPLOAD_DIR, file.filename);
if (!fs.existsSync(fp)) return res.status(404).json({ error: 'Datei fehlt' });
res.download(fp, file.originalname);
});
// Share-Management // Share-Management
router.get('/:id/shares', authenticate, (req, res) => { router.get('/:id/shares', authenticate, (req, res) => {
const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id); const calc = db.prepare('SELECT * FROM calculations WHERE id=? AND user_id=?').get(req.params.id, req.user.id);

View File

@@ -541,6 +541,68 @@ export default function Kalkulator3D({ toast, editData, onEditDone, mobile, setA
} }
// ── Archiv ────────────────────────────────────────────────────────────── // ── Archiv ──────────────────────────────────────────────────────────────
// ── Datei-Downloads für ein Archiv-Modell ────────────────────────────────────
function ModelFiles({ calcId, toast }) {
const [files, setFiles] = useState(null); // null = noch nicht geladen
const [open, setOpen] = useState(false);
const load = async () => {
if (files !== null) return;
try { setFiles(await api(`/tools/kalkulator3d/${calcId}/files`)); }
catch { setFiles([]); }
};
const download = async f => {
try {
const res = await fetch(`/api/tools/kalkulator3d/${calcId}/files/${f.id}/download`, {
headers:{ Authorization:`Bearer ${localStorage.getItem('sk_token')}` }
});
if (!res.ok) { toast('Download fehlgeschlagen','error'); return; }
const blob = await res.blob();
const a = Object.assign(document.createElement('a'),{ href:URL.createObjectURL(blob), download:f.originalname });
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(()=>URL.revokeObjectURL(a.href),1000);
} catch(e){ toast(e.message,'error'); }
};
const toggle = () => { if (!open) load(); setOpen(v=>!v); };
return (
<div>
<button onClick={toggle} style={{ ...S.btn('#4ecdc4', true), width:'100%', textAlign:'left',
display:'flex', alignItems:'center', gap:6, fontSize:11 }}>
<span>🖨</span>
<span style={{flex:1}}>3D-Dateien {files!==null&&files.length>0?`(${files.length})`:''}</span>
<span style={{transform:open?'rotate(90deg)':'rotate(0)',transition:'transform 0.2s',fontSize:10}}></span>
</button>
{open && (
<div style={{ marginTop:6, background:'rgba(255,255,255,0.02)', border:'1px solid rgba(255,255,255,0.07)',
borderRadius:8, overflow:'hidden' }}>
{files === null ? (
<div style={{ padding:'10px 14px', color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:11 }}>Lädt</div>
) : files.length === 0 ? (
<div style={{ padding:'10px 14px', color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:11 }}>Keine Dateien vorhanden</div>
) : files.map((f,i) => (
<div key={f.id} style={{ display:'flex', alignItems:'center', gap:8, padding:'8px 12px',
borderBottom: i<files.length-1 ? '1px solid rgba(255,255,255,0.05)' : 'none' }}>
<span style={{ color:'rgba(255,255,255,0.6)', fontFamily:'monospace', fontSize:11,
flex:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>
{f.originalname}
</span>
<span style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:9, flexShrink:0 }}>
{(f.size/1024/1024).toFixed(1)} MB
</span>
<button onClick={()=>download(f)} style={{ ...S.btn('#4ecdc4'), padding:'4px 10px', fontSize:10, flexShrink:0 }}>
</button>
</div>
))}
</div>
)}
</div>
);
}
// ── Share Modal ─────────────────────────────────────────────────────────────── // ── Share Modal ───────────────────────────────────────────────────────────────
function CalcShareModal({ item, onClose, toast }) { function CalcShareModal({ item, onClose, toast }) {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
@@ -604,17 +666,18 @@ export function SavedList({ toast, mobile }) {
const { confirm, ConfirmDialog } = useConfirm(); const { confirm, ConfirmDialog } = useConfirm();
const [own, setOwn] = useState([]); const [own, setOwn] = useState([]);
const [shared, setShared] = useState([]); const [shared, setShared] = useState([]);
const [sharedByMe, setSharedByMe] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [tab, setTab] = useState('own'); const [tab, setTab] = useState('own');
const [editData, setEditData] = useState(null); const [editData, setEditData] = useState(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [lightbox, setLightbox] = useState(null); const [lightbox, setLightbox] = useState(null);
const [shareItem,setShareItem]= useState(null); const [shareItem, setShareItem]= useState(null);
const load = () => { const load = () => {
setLoading(true); setLoading(true);
api('/tools/kalkulator3d') api('/tools/kalkulator3d')
.then(d => { setOwn(d.own||[]); setShared(d.shared||[]); }) .then(d => { setOwn(d.own||[]); setShared(d.shared||[]); setSharedByMe(d.sharedByMe||[]); })
.catch(e => toast(e.message,'error')) .catch(e => toast(e.message,'error'))
.finally(()=>setLoading(false)); .finally(()=>setLoading(false));
}; };
@@ -625,7 +688,7 @@ export function SavedList({ toast, mobile }) {
catch(e) { toast(e.message,'error'); } catch(e) { toast(e.message,'error'); }
}; };
const items = tab==='own' ? own : shared; const items = tab==='own' ? own : tab==='shared' ? shared : sharedByMe;
const filtered = search.trim() const filtered = search.trim()
? items.filter(i => i.name.toLowerCase().includes(search.toLowerCase()) || (i.bemerkung&&i.bemerkung.toLowerCase().includes(search.toLowerCase()))) ? items.filter(i => i.name.toLowerCase().includes(search.toLowerCase()) || (i.bemerkung&&i.bemerkung.toLowerCase().includes(search.toLowerCase())))
: items; : items;
@@ -648,8 +711,8 @@ export function SavedList({ toast, mobile }) {
</div> </div>
{/* Tabs */} {/* Tabs */}
<div style={{ display:'flex', gap:6, marginBottom:14 }}> <div style={{ display:'flex', gap:6, marginBottom:14, flexWrap:'wrap' }}>
{[['own','Meine',own.length],['shared','Geteilt mit mir',shared.length]].map(([k,l,cnt])=>( {[['own','Meine',own.length],['shared','Geteilt mit mir',shared.length],['sharedByMe','Geteilt von mir',sharedByMe.length]].map(([k,l,cnt])=>(
<button key={k} onClick={()=>{setTab(k);setSearch('');}} style={{ <button key={k} onClick={()=>{setTab(k);setSearch('');}} style={{
padding:'6px 14px', borderRadius:20, fontFamily:'monospace', fontSize:11, cursor:'pointer', padding:'6px 14px', borderRadius:20, fontFamily:'monospace', fontSize:11, cursor:'pointer',
background: tab===k ? '#4ecdc4' : 'rgba(255,255,255,0.05)', background: tab===k ? '#4ecdc4' : 'rgba(255,255,255,0.05)',
@@ -751,13 +814,22 @@ export function SavedList({ toast, mobile }) {
</div> </div>
{/* Aktionen */} {/* Aktionen */}
<div style={{ display:'flex', gap:6, marginTop:10 }}> <div style={{ display:'flex', gap:6, marginTop:10, flexDirection:'column' }}>
{!item.is_shared ? ( {/* Datei-Downloads */}
{item.has_files && (
<ModelFiles calcId={item.id} toast={toast}/>
)}
<div style={{ display:'flex', gap:6 }}>
{!item.is_shared && tab !== 'sharedByMe' ? (
<> <>
<button onClick={() => setEditData(item)} style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center' }}> Bearbeiten</button> <button onClick={() => setEditData(item)} style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center' }}> Bearbeiten</button>
<button onClick={() => setShareItem(item)} style={{ ...S.btn('#ffe66d', true), padding:'0 12px' }} title="Teilen">🤝</button> <button onClick={() => setShareItem(item)} style={{ ...S.btn('#ffe66d', true), padding:'0 12px' }} title="Teilen">🤝</button>
<button onClick={() => del(item.id)} style={{ ...S.btn('#ff6b9d', true), padding:'0 12px' }} title="Löschen"></button> <button onClick={() => del(item.id)} style={{ ...S.btn('#ff6b9d', true), padding:'0 12px' }} title="Löschen"></button>
</> </>
) : tab === 'sharedByMe' ? (
<div style={{ color:'rgba(255,255,255,0.3)', fontFamily:'monospace', fontSize:10, padding:'4px 0' }}>
{item.shared_with_name}
</div>
) : ( ) : (
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:10, padding:'6px 0' }}> <div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:10, padding:'6px 0' }}>
von {item.owner_name} · nur Ansicht von {item.owner_name} · nur Ansicht
@@ -765,6 +837,7 @@ export function SavedList({ toast, mobile }) {
)} )}
</div> </div>
</div> </div>
</div>
))} ))}
</div> </div>
); );