Archiv: Geteilt-von-mir Tab + 3D-Datei Downloads für Owner und Empfänger
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user