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
|
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);
|
||||||
|
|||||||
@@ -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('');
|
||||||
@@ -602,19 +664,20 @@ function CalcShareModal({ item, onClose, toast }) {
|
|||||||
|
|
||||||
export function SavedList({ toast, mobile }) {
|
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 [loading, setLoading] = useState(true);
|
const [sharedByMe, setSharedByMe] = useState([]);
|
||||||
const [tab, setTab] = useState('own');
|
const [loading, setLoading] = useState(true);
|
||||||
const [editData, setEditData] = useState(null);
|
const [tab, setTab] = useState('own');
|
||||||
const [search, setSearch] = useState('');
|
const [editData, setEditData] = useState(null);
|
||||||
const [lightbox, setLightbox] = useState(null);
|
const [search, setSearch] = useState('');
|
||||||
const [shareItem,setShareItem]= useState(null);
|
const [lightbox, setLightbox] = 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,18 +814,28 @@ 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 && (
|
||||||
<button onClick={() => setEditData(item)} style={{ ...S.btn('#4ecdc4', true), flex:1, textAlign:'center' }}>✎ Bearbeiten</button>
|
<ModelFiles calcId={item.id} toast={toast}/>
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div style={{ color:'rgba(255,255,255,0.25)', fontFamily:'monospace', fontSize:10, padding:'6px 0' }}>
|
|
||||||
von {item.owner_name} · nur Ansicht
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<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
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user