Sharing fixes, live preview
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m19s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m19s
This commit is contained in:
parent
27fd6fa4f5
commit
a52f1d63f7
4 changed files with 350 additions and 92 deletions
|
|
@ -1,10 +1,13 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"fmt"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.elijahkuntz.com/Elijah/drive/config"
|
||||
|
|
@ -194,27 +197,58 @@ func (h *ShareHandler) GetPublicShare(c *fiber.Ctx) error {
|
|||
}
|
||||
|
||||
// Send file info
|
||||
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(path))
|
||||
targetRelPath := filepath.Clean(filepath.FromSlash(path))
|
||||
if subPath := c.Query("path"); subPath != "" {
|
||||
cleanSub := filepath.Clean(filepath.FromSlash(subPath))
|
||||
if strings.HasPrefix(cleanSub, "..") || strings.HasPrefix(cleanSub, "/") || strings.HasPrefix(cleanSub, "\\") {
|
||||
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{"error": "invalid path"})
|
||||
}
|
||||
targetRelPath = filepath.Join(targetRelPath, cleanSub)
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(h.Config.StorageDir, targetRelPath)
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "file no longer exists"})
|
||||
}
|
||||
|
||||
// We return a signed download token or just allow the immediate request.
|
||||
// For simplicity, we'll return a short-lived download token JWT.
|
||||
// Actually, easier: the frontend calls GET /api/public/download/:id?pwd=xxx
|
||||
|
||||
mimeType := mime.TypeByExtension(filepath.Ext(info.Name()))
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"name": filepath.Base(path),
|
||||
response := fiber.Map{
|
||||
"name": info.Name(), // Use info.Name() instead of base of original path
|
||||
"size": info.Size(),
|
||||
"is_dir": info.IsDir(),
|
||||
"mime_type": mimeType,
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
var files []fiber.Map
|
||||
entries, err := os.ReadDir(fullPath)
|
||||
if err == nil {
|
||||
for _, entry := range entries {
|
||||
entryInfo, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
eMime := mime.TypeByExtension(filepath.Ext(entry.Name()))
|
||||
if eMime == "" {
|
||||
eMime = "application/octet-stream"
|
||||
}
|
||||
files = append(files, fiber.Map{
|
||||
"name": entry.Name(),
|
||||
"size": entryInfo.Size(),
|
||||
"is_dir": entry.IsDir(),
|
||||
"mime_type": eMime,
|
||||
})
|
||||
}
|
||||
}
|
||||
response["files"] = files
|
||||
}
|
||||
|
||||
return c.JSON(response)
|
||||
}
|
||||
|
||||
// DownloadPublicShare serves the file.
|
||||
|
|
@ -252,11 +286,67 @@ func (h *ShareHandler) DownloadPublicShare(c *fiber.Ctx) error {
|
|||
}
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(h.Config.StorageDir, filepath.FromSlash(path))
|
||||
targetRelPath := filepath.Clean(filepath.FromSlash(path))
|
||||
if subPath := c.Query("path"); subPath != "" {
|
||||
cleanSub := filepath.Clean(filepath.FromSlash(subPath))
|
||||
if strings.HasPrefix(cleanSub, "..") || strings.HasPrefix(cleanSub, "/") || strings.HasPrefix(cleanSub, "\\") {
|
||||
return c.Status(fiber.StatusForbidden).SendString("invalid path")
|
||||
}
|
||||
targetRelPath = filepath.Join(targetRelPath, cleanSub)
|
||||
}
|
||||
|
||||
fullPath := filepath.Join(h.Config.StorageDir, targetRelPath)
|
||||
info, err := os.Stat(fullPath)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusNotFound).SendString("not found")
|
||||
}
|
||||
|
||||
// Increment download counter
|
||||
h.DB.Exec(`UPDATE share_links SET download_count = download_count + 1 WHERE token = ?`, id)
|
||||
h.DB.AddAuditLog("share_downloaded", fmt.Sprintf("Downloaded share %s", id), c.IP())
|
||||
h.DB.AddAuditLog("share_downloaded", fmt.Sprintf("Downloaded share %s path %s", id, targetRelPath), c.IP())
|
||||
|
||||
if info.IsDir() {
|
||||
c.Set("Content-Type", "application/zip")
|
||||
c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.zip\"", filepath.Base(fullPath)))
|
||||
|
||||
c.Context().SetBodyStreamWriter(func(w *bufio.Writer) {
|
||||
zipWriter := zip.NewWriter(w)
|
||||
defer zipWriter.Close()
|
||||
|
||||
filepath.Walk(fullPath, func(p string, i os.FileInfo, e error) error {
|
||||
if e != nil || p == fullPath {
|
||||
return e
|
||||
}
|
||||
|
||||
relPath, e := filepath.Rel(fullPath, p)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
|
||||
if i.IsDir() {
|
||||
relPath += "/"
|
||||
_, e = zipWriter.Create(relPath)
|
||||
return e
|
||||
}
|
||||
|
||||
zipFile, e := zipWriter.Create(relPath)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
|
||||
fsFile, e := os.Open(p)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
defer fsFile.Close()
|
||||
|
||||
// Write file to zip
|
||||
_, e = bufio.NewReader(fsFile).WriteTo(zipFile)
|
||||
return e
|
||||
})
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
return c.SendFile(fullPath)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,17 @@
|
|||
'use client';
|
||||
|
||||
import { use, useState, useEffect } from 'react';
|
||||
import { Shield, Download, Lock, File, Folder, Loader2 } from 'lucide-react';
|
||||
import { Shield, Download, Lock, File, Folder, Loader2, ChevronRight, Home, Image as ImageIcon, Film, Music, FileText, Archive } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
|
||||
interface FileInfo {
|
||||
name: string;
|
||||
size: number;
|
||||
is_dir: boolean;
|
||||
mime_type?: string;
|
||||
files?: FileInfo[];
|
||||
}
|
||||
|
||||
export default function PublicSharePage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const resolvedParams = use(params);
|
||||
const id = resolvedParams.id;
|
||||
|
|
@ -11,17 +19,18 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [requirePassword, setRequirePassword] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [fileInfo, setFileInfo] = useState<{ name: string; size: number; is_dir: boolean; mime_type?: string } | null>(null);
|
||||
const [fileInfo, setFileInfo] = useState<FileInfo | null>(null);
|
||||
const [currentPath, setCurrentPath] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchShareInfo();
|
||||
}, []);
|
||||
fetchShareInfo(password, currentPath);
|
||||
}, [currentPath]);
|
||||
|
||||
async function fetchShareInfo(pwd?: string) {
|
||||
async function fetchShareInfo(pwd?: string, path?: string) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await api.getPublicShare(id, pwd);
|
||||
const data = await api.getPublicShare(id, pwd, path);
|
||||
if (data.require_password) {
|
||||
setRequirePassword(true);
|
||||
} else {
|
||||
|
|
@ -41,11 +50,12 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri
|
|||
|
||||
function handlePasswordSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
fetchShareInfo(password);
|
||||
fetchShareInfo(password, currentPath);
|
||||
}
|
||||
|
||||
function handleDownload() {
|
||||
const url = api.getPublicDownloadUrl(id, password);
|
||||
function handleDownload(specificPath?: string) {
|
||||
const downloadPath = specificPath ? (currentPath ? `${currentPath}/${specificPath}` : specificPath) : currentPath;
|
||||
const url = api.getPublicDownloadUrl(id, password, downloadPath);
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
|
|
@ -56,21 +66,31 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri
|
|||
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
function getFileIcon(isDir: boolean, mimeType?: string) {
|
||||
if (isDir) return <Folder className="w-5 h-5 text-blue-500" />;
|
||||
if (mimeType?.startsWith('image/')) return <ImageIcon className="w-5 h-5 text-green-500" />;
|
||||
if (mimeType?.startsWith('video/')) return <Film className="w-5 h-5 text-purple-500" />;
|
||||
if (mimeType?.startsWith('audio/')) return <Music className="w-5 h-5 text-yellow-500" />;
|
||||
if (mimeType === 'application/pdf') return <FileText className="w-5 h-5 text-red-500" />;
|
||||
if (mimeType === 'application/zip' || mimeType === 'application/x-gzip') return <Archive className="w-5 h-5 text-orange-500" />;
|
||||
return <File className="w-5 h-5 text-gray-400" />;
|
||||
}
|
||||
|
||||
if (loading && !fileInfo) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
||||
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--color-text-tertiary)' }} />
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-[#0f172a] to-[#1e1b4b]">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-white/50" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
||||
<div className="max-w-md w-full p-8 rounded-2xl text-center shadow-xl" style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
||||
<Shield className="w-12 h-12 mx-auto mb-4" style={{ color: 'var(--color-danger)' }} />
|
||||
<h1 className="text-2xl font-bold mb-2" style={{ color: 'var(--color-text-primary)' }}>Access Denied</h1>
|
||||
<p style={{ color: 'var(--color-text-secondary)' }}>{error}</p>
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#0f172a] to-[#1e1b4b]">
|
||||
<div className="max-w-md w-full p-8 rounded-3xl text-center bg-white/10 backdrop-blur-xl border border-white/20 shadow-2xl">
|
||||
<Shield className="w-16 h-16 mx-auto mb-6 text-red-400" />
|
||||
<h1 className="text-2xl font-bold mb-3 text-white">Access Denied</h1>
|
||||
<p className="text-white/70">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -78,85 +98,189 @@ export default function PublicSharePage({ params }: { params: Promise<{ id: stri
|
|||
|
||||
if (requirePassword) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
||||
<div className="max-w-md w-full p-8 rounded-2xl shadow-xl" style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-gradient-to-br from-[#0f172a] to-[#1e1b4b]">
|
||||
<div className="max-w-md w-full p-8 rounded-3xl bg-white/10 backdrop-blur-xl border border-white/20 shadow-2xl">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4" style={{ backgroundColor: 'var(--color-accent-subtle)' }}>
|
||||
<Lock className="w-8 h-8" style={{ color: 'var(--color-accent)' }} />
|
||||
<div className="w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6 bg-white/5 border border-white/10">
|
||||
<Lock className="w-10 h-10 text-white/80" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold mb-2" style={{ color: 'var(--color-text-primary)' }}>Protected File</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>This shared file is password protected.</p>
|
||||
<h1 className="text-2xl font-bold mb-2 text-white">Protected File</h1>
|
||||
<p className="text-white/60">This shared link is password protected.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handlePasswordSubmit} className="space-y-4">
|
||||
<form onSubmit={handlePasswordSubmit} className="space-y-6">
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
className="w-full px-4 py-3 rounded-xl outline-none"
|
||||
style={{ backgroundColor: 'var(--color-bg-tertiary)', border: '1px solid var(--color-border)', color: 'var(--color-text-primary)' }}
|
||||
className="w-full px-5 py-4 rounded-xl outline-none bg-black/20 border border-white/10 text-white placeholder-white/40 focus:border-white/30 transition-colors"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full py-3 rounded-xl text-white font-medium transition-opacity hover:opacity-90 shadow-lg"
|
||||
style={{ background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)' }}
|
||||
className="w-full py-4 rounded-xl text-white font-semibold transition-all hover:scale-[1.02] active:scale-[0.98] shadow-lg"
|
||||
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }}
|
||||
>
|
||||
Unlock
|
||||
Unlock Access
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const isImage = fileInfo?.mime_type?.startsWith('image/');
|
||||
const isVideo = fileInfo?.mime_type?.startsWith('video/');
|
||||
const isAudio = fileInfo?.mime_type?.startsWith('audio/');
|
||||
const isPdf = fileInfo?.mime_type === 'application/pdf';
|
||||
const downloadUrl = fileInfo ? api.getPublicDownloadUrl(id, password) : '';
|
||||
|
||||
const isImage = !fileInfo?.is_dir && fileInfo?.mime_type?.startsWith('image/');
|
||||
const isVideo = !fileInfo?.is_dir && fileInfo?.mime_type?.startsWith('video/');
|
||||
const isAudio = !fileInfo?.is_dir && fileInfo?.mime_type?.startsWith('audio/');
|
||||
const isPdf = !fileInfo?.is_dir && fileInfo?.mime_type === 'application/pdf';
|
||||
const downloadUrl = fileInfo ? api.getPublicDownloadUrl(id, password, currentPath) : '';
|
||||
|
||||
// Breadcrumbs
|
||||
const pathParts = currentPath.split('/').filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
||||
<div className="max-w-md w-full p-8 rounded-2xl text-center shadow-xl" style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
||||
<div className="mb-6 flex justify-center">
|
||||
{fileInfo?.is_dir ? (
|
||||
<div className="w-24 h-24 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--color-accent-subtle)' }}>
|
||||
<Folder className="w-12 h-12" style={{ color: 'var(--color-accent)' }} />
|
||||
<div className="min-h-screen p-4 md:p-8 bg-gradient-to-br from-[#0f172a] to-[#1e1b4b] text-white flex flex-col items-center">
|
||||
<div className={`w-full ${fileInfo?.is_dir ? 'max-w-4xl' : 'max-w-md'} transition-all duration-500`}>
|
||||
|
||||
{/* Header/Card */}
|
||||
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-3xl shadow-2xl overflow-hidden">
|
||||
|
||||
<div className="p-8">
|
||||
{/* Top Bar for Directories */}
|
||||
{fileInfo?.is_dir && (
|
||||
<div className="flex items-center justify-between mb-8 pb-6 border-b border-white/10">
|
||||
<div className="flex items-center gap-2 text-white/80 overflow-x-auto whitespace-nowrap scrollbar-hide">
|
||||
<button onClick={() => setCurrentPath('')} className="hover:text-white transition-colors flex items-center gap-2">
|
||||
<Home className="w-5 h-5" />
|
||||
<span className="font-medium">Shared Folder</span>
|
||||
</button>
|
||||
{pathParts.map((part, idx) => {
|
||||
const pathToHere = pathParts.slice(0, idx + 1).join('/');
|
||||
return (
|
||||
<div key={pathToHere} className="flex items-center gap-2">
|
||||
<ChevronRight className="w-4 h-4 text-white/40" />
|
||||
<button
|
||||
onClick={() => setCurrentPath(pathToHere)}
|
||||
className="hover:text-white transition-colors font-medium truncate max-w-[150px]"
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</div>
|
||||
) : isImage ? (
|
||||
<img src={downloadUrl} alt={fileInfo?.name} className="w-full max-h-48 object-contain rounded-xl" />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleDownload()}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-xl font-medium transition-all hover:bg-white/10 bg-white/5 border border-white/10 text-white shadow-lg"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download ZIP
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!fileInfo?.is_dir ? (
|
||||
// Single File View
|
||||
<div className="text-center">
|
||||
<div className="mb-8 flex justify-center">
|
||||
{isImage ? (
|
||||
<img src={downloadUrl} alt={fileInfo?.name} className="w-full max-h-64 object-contain rounded-2xl shadow-lg border border-white/10 bg-black/20" />
|
||||
) : isVideo ? (
|
||||
<video src={downloadUrl} controls className="w-full max-h-48 rounded-xl" />
|
||||
<video src={downloadUrl} controls className="w-full max-h-64 rounded-2xl shadow-lg border border-white/10 bg-black/20" />
|
||||
) : isAudio ? (
|
||||
<audio src={downloadUrl} controls className="w-full mt-4" />
|
||||
<div className="w-full bg-black/20 p-6 rounded-2xl border border-white/10 shadow-lg">
|
||||
<Music className="w-16 h-16 mx-auto mb-6 text-yellow-400 opacity-80" />
|
||||
<audio src={downloadUrl} controls className="w-full" />
|
||||
</div>
|
||||
) : isPdf ? (
|
||||
<iframe src={downloadUrl} className="w-full h-64 rounded-xl border-none" />
|
||||
<iframe src={downloadUrl} className="w-full h-80 rounded-2xl border border-white/10 shadow-lg bg-white" />
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-2xl flex items-center justify-center" style={{ backgroundColor: 'var(--color-accent-subtle)' }}>
|
||||
<File className="w-12 h-12" style={{ color: 'var(--color-accent)' }} />
|
||||
<div className="w-32 h-32 rounded-3xl flex items-center justify-center bg-gradient-to-br from-indigo-500/20 to-purple-500/20 border border-white/10 shadow-inner">
|
||||
<File className="w-16 h-16 text-indigo-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="text-xl font-bold mb-1 truncate px-4" style={{ color: 'var(--color-text-primary)' }} title={fileInfo?.name}>
|
||||
<h1 className="text-2xl font-bold mb-2 truncate px-4 text-white" title={fileInfo?.name}>
|
||||
{fileInfo?.name}
|
||||
</h1>
|
||||
<p className="text-sm mb-8" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
{fileInfo?.is_dir ? 'Folder' : formatSize(fileInfo?.size || 0)}
|
||||
<p className="text-white/60 mb-8 font-medium">
|
||||
{formatSize(fileInfo?.size || 0)}
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="w-full flex items-center justify-center gap-2 py-3.5 rounded-xl text-white font-medium transition-opacity hover:opacity-90 shadow-lg"
|
||||
style={{ background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)' }}
|
||||
onClick={() => handleDownload()}
|
||||
className="w-full flex items-center justify-center gap-2 py-4 rounded-xl text-white font-semibold transition-all hover:scale-[1.02] active:scale-[0.98] shadow-[0_0_20px_rgba(99,102,241,0.4)]"
|
||||
style={{ background: 'linear-gradient(135deg, #6366f1, #8b5cf6)' }}
|
||||
>
|
||||
<Download className="w-5 h-5" />
|
||||
Download
|
||||
Download File
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// Directory Listing View
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 text-white/50 text-sm">
|
||||
<th className="pb-4 font-medium pl-4">Name</th>
|
||||
<th className="pb-4 font-medium">Size</th>
|
||||
<th className="pb-4 font-medium text-right pr-4">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/5">
|
||||
{fileInfo.files?.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="py-12 text-center text-white/40">
|
||||
This folder is empty.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
fileInfo.files?.map((f) => (
|
||||
<tr
|
||||
key={f.name}
|
||||
className={`group transition-colors ${f.is_dir ? 'cursor-pointer hover:bg-white/5' : 'hover:bg-white/5'}`}
|
||||
onClick={() => {
|
||||
if (f.is_dir) {
|
||||
setCurrentPath(currentPath ? `${currentPath}/${f.name}` : f.name);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className="py-4 pl-4 flex items-center gap-4">
|
||||
{getFileIcon(f.is_dir, f.mime_type)}
|
||||
<span className="font-medium text-white/90 group-hover:text-white truncate max-w-[200px] sm:max-w-[400px]">
|
||||
{f.name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 text-white/50 text-sm">
|
||||
{f.is_dir ? '--' : formatSize(f.size)}
|
||||
</td>
|
||||
<td className="py-4 pr-4 text-right">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDownload(f.name);
|
||||
}}
|
||||
className="p-2 rounded-lg bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors ml-auto flex items-center justify-center"
|
||||
title={`Download ${f.is_dir ? 'folder as ZIP' : 'file'}`}
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export default function SharedFilesPage() {
|
|||
const [shares, setShares] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [shareToRevoke, setShareToRevoke] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadShares();
|
||||
|
|
@ -26,11 +27,16 @@ export default function SharedFilesPage() {
|
|||
}
|
||||
}
|
||||
|
||||
async function handleRevoke(id: string) {
|
||||
if (!confirm('Are you sure you want to revoke this share link?')) return;
|
||||
function handleRevoke(id: string) {
|
||||
setShareToRevoke(id);
|
||||
}
|
||||
|
||||
async function confirmRevoke() {
|
||||
if (!shareToRevoke) return;
|
||||
try {
|
||||
await api.revokeShare(id);
|
||||
setShares(prev => prev.filter(s => s.id !== id));
|
||||
await api.revokeShare(shareToRevoke);
|
||||
setShares(prev => prev.filter(s => s.id !== shareToRevoke));
|
||||
setShareToRevoke(null);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
|
|
@ -120,6 +126,36 @@ export default function SharedFilesPage() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shareToRevoke && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ backgroundColor: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)' }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="w-full max-w-md p-6 rounded-2xl shadow-xl"
|
||||
style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--color-text-primary)' }}>Revoke Share Link</h3>
|
||||
<p className="text-sm mb-6" style={{ color: 'var(--color-text-secondary)' }}>Are you sure you want to revoke this share link? Anyone with the link will lose access immediately.</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setShareToRevoke(null)}
|
||||
className="px-4 py-2 rounded-xl text-sm font-medium transition-colors"
|
||||
style={{ backgroundColor: 'var(--color-bg-secondary)', color: 'var(--color-text-primary)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmRevoke}
|
||||
className="px-4 py-2 rounded-xl text-sm font-medium transition-colors hover:bg-red-500/20"
|
||||
style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', color: '#ef4444' }}
|
||||
>
|
||||
Revoke Link
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -351,9 +351,13 @@ class ApiClient {
|
|||
return res.json();
|
||||
}
|
||||
|
||||
async getPublicShare(id: string, password?: string) {
|
||||
async getPublicShare(id: string, password?: string, path?: string) {
|
||||
// Note: No auth headers here, so we use fetch directly to avoid interceptors
|
||||
const res = await fetch(`${API_BASE}/api/public/share/${id}`, {
|
||||
let url = `${API_BASE}/api/public/share/${id}`;
|
||||
if (path) {
|
||||
url += `?path=${encodeURIComponent(path)}`;
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
|
|
@ -365,10 +369,14 @@ class ApiClient {
|
|||
return res.json();
|
||||
}
|
||||
|
||||
getPublicDownloadUrl(id: string, password?: string) {
|
||||
getPublicDownloadUrl(id: string, password?: string, path?: string) {
|
||||
let url = `${API_BASE}/api/public/download/${id}`;
|
||||
if (password) {
|
||||
url += `?pwd=${encodeURIComponent(password)}`;
|
||||
const params = new URLSearchParams();
|
||||
if (password) params.set('pwd', password);
|
||||
if (path) params.set('path', path);
|
||||
const qs = params.toString();
|
||||
if (qs) {
|
||||
url += `?${qs}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
|
|
|||
Reference in a new issue