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
`).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({
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
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);

View File

@@ -541,6 +541,68 @@ export default function Kalkulator3D({ toast, editData, onEditDone, mobile, setA
}
// ── 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 ───────────────────────────────────────────────────────────────
function CalcShareModal({ item, onClose, toast }) {
const [username, setUsername] = useState('');
@@ -604,6 +666,7 @@ export function SavedList({ toast, mobile }) {
const { confirm, ConfirmDialog } = useConfirm();
const [own, setOwn] = useState([]);
const [shared, setShared] = useState([]);
const [sharedByMe, setSharedByMe] = useState([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState('own');
const [editData, setEditData] = useState(null);
@@ -614,7 +677,7 @@ export function SavedList({ toast, mobile }) {
const load = () => {
setLoading(true);
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'))
.finally(()=>setLoading(false));
};
@@ -625,7 +688,7 @@ export function SavedList({ toast, mobile }) {
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()
? items.filter(i => i.name.toLowerCase().includes(search.toLowerCase()) || (i.bemerkung&&i.bemerkung.toLowerCase().includes(search.toLowerCase())))
: items;
@@ -648,8 +711,8 @@ export function SavedList({ toast, mobile }) {
</div>
{/* Tabs */}
<div style={{ display:'flex', gap:6, marginBottom:14 }}>
{[['own','Meine',own.length],['shared','Geteilt mit mir',shared.length]].map(([k,l,cnt])=>(
<div style={{ display:'flex', gap:6, marginBottom:14, flexWrap:'wrap' }}>
{[['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={{
padding:'6px 14px', borderRadius:20, fontFamily:'monospace', fontSize:11, cursor:'pointer',
background: tab===k ? '#4ecdc4' : 'rgba(255,255,255,0.05)',
@@ -751,13 +814,22 @@ export function SavedList({ toast, mobile }) {
</div>
{/* Aktionen */}
<div style={{ display:'flex', gap:6, marginTop:10 }}>
{!item.is_shared ? (
<div style={{ display:'flex', gap:6, marginTop:10, flexDirection:'column' }}>
{/* 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={() => 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>
</>
) : 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' }}>
von {item.owner_name} · nur Ansicht
@@ -765,6 +837,7 @@ export function SavedList({ toast, mobile }) {
)}
</div>
</div>
</div>
))}
</div>
);