Initial onlyoffice integration, sidebar changes
Some checks failed
Automated Container Build / build-and-push (push) Failing after 28s
Some checks failed
Automated Container Build / build-and-push (push) Failing after 28s
This commit is contained in:
parent
4110657557
commit
d80e31e7f5
7 changed files with 504 additions and 12 deletions
129
backend/handlers/onlyoffice.go
Normal file
129
backend/handlers/onlyoffice.go
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"git.elijahkuntz.com/Elijah/drive/config"
|
||||||
|
"git.elijahkuntz.com/Elijah/drive/middleware"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OnlyOfficeHandler struct {
|
||||||
|
Config *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
type CallbackRequest struct {
|
||||||
|
Status int `json:"status"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *OnlyOfficeHandler) Callback(c *fiber.Ctx) error {
|
||||||
|
path := c.Query("path")
|
||||||
|
token := c.Query("token")
|
||||||
|
|
||||||
|
if path == "" {
|
||||||
|
return c.Status(400).SendString("path required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the token to ensure the callback is authorized
|
||||||
|
if err := middleware.ValidateDownloadToken(token, h.Config.JWTSecret, ""); err != nil {
|
||||||
|
return c.Status(401).SendString("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req CallbackRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "invalid json"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status 2: Document is ready for saving
|
||||||
|
// Status 6: Document is being edited, force save was triggered
|
||||||
|
if req.Status == 2 || req.Status == 6 {
|
||||||
|
if req.Url == "" {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "url required"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download modified file from Document Server
|
||||||
|
resp, err := http.Get(req.Url)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": "failed to download file"})
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
targetPath := filepath.Join(h.Config.StorageDir, path)
|
||||||
|
|
||||||
|
// Save file
|
||||||
|
out, err := os.Create(targetPath)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": "failed to open file for writing"})
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(out, resp.Body); err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": "failed to write file"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send successful response according to OnlyOffice API
|
||||||
|
return c.JSON(fiber.Map{"error": 0})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBlank creates an empty document for Docs or Slides mode
|
||||||
|
func (h *OnlyOfficeHandler) CreateBlank(c *fiber.Ctx) error {
|
||||||
|
req := new(struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := c.BodyParser(req); err != nil {
|
||||||
|
return c.Status(400).JSON(fiber.Map{"error": "invalid request"})
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Name == "" {
|
||||||
|
req.Name = "Untitled Document"
|
||||||
|
if req.Type == "slide" {
|
||||||
|
req.Name = "Untitled Presentation"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := ".docx"
|
||||||
|
if req.Type == "slide" {
|
||||||
|
ext = ".pptx"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add extension if missing
|
||||||
|
if filepath.Ext(req.Name) != ext {
|
||||||
|
req.Name += ext
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure the file name doesn't collide
|
||||||
|
basePath := filepath.Join(h.Config.StorageDir, req.Name)
|
||||||
|
finalPath := basePath
|
||||||
|
counter := 1
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(finalPath); os.IsNotExist(err) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
finalPath = filepath.Join(h.Config.StorageDir, fmt.Sprintf("%s (%d)%s", req.Name[:len(req.Name)-len(ext)], counter, ext))
|
||||||
|
counter++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an empty file. OnlyOffice will initialize it when opened.
|
||||||
|
f, err := os.Create(finalPath)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(500).JSON(fiber.Map{"error": "failed to create file"})
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
|
||||||
|
// Return the relative path
|
||||||
|
relativePath, _ := filepath.Rel(h.Config.StorageDir, finalPath)
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"path": filepath.ToSlash(relativePath),
|
||||||
|
"name": filepath.Base(finalPath),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -69,6 +69,7 @@ func main() {
|
||||||
archiveHandler := &handlers.ArchiveHandler{DB: db, Config: cfg}
|
archiveHandler := &handlers.ArchiveHandler{DB: db, Config: cfg}
|
||||||
shareGuard := middleware.NewBruteForceGuard(3, 60)
|
shareGuard := middleware.NewBruteForceGuard(3, 60)
|
||||||
shareHandler := &handlers.ShareHandler{DB: db, Config: cfg, Guard: shareGuard}
|
shareHandler := &handlers.ShareHandler{DB: db, Config: cfg, Guard: shareGuard}
|
||||||
|
onlyOfficeHandler := &handlers.OnlyOfficeHandler{Config: cfg}
|
||||||
|
|
||||||
// Initialize background workers
|
// Initialize background workers
|
||||||
workers.InitTaskManager(cfg)
|
workers.InitTaskManager(cfg)
|
||||||
|
|
@ -135,6 +136,9 @@ func main() {
|
||||||
public.Post("/share/:id", shareGuard.Check(), shareHandler.GetPublicShare)
|
public.Post("/share/:id", shareGuard.Check(), shareHandler.GetPublicShare)
|
||||||
public.Get("/download/:id", shareHandler.DownloadPublicShare)
|
public.Get("/download/:id", shareHandler.DownloadPublicShare)
|
||||||
|
|
||||||
|
// OnlyOffice callback (public but validates query token)
|
||||||
|
public.Post("/onlyoffice/callback", onlyOfficeHandler.Callback)
|
||||||
|
|
||||||
// Auth routes (with brute force protection)
|
// Auth routes (with brute force protection)
|
||||||
auth := app.Group("/api/auth")
|
auth := app.Group("/api/auth")
|
||||||
auth.Post("/login", guard.Check(), authHandler.Login)
|
auth.Post("/login", guard.Check(), authHandler.Login)
|
||||||
|
|
@ -184,6 +188,9 @@ func main() {
|
||||||
protected.Post("/files/upload", fsHandler.Upload)
|
protected.Post("/files/upload", fsHandler.Upload)
|
||||||
protected.Post("/files/check-conflicts", fsHandler.CheckConflicts)
|
protected.Post("/files/check-conflicts", fsHandler.CheckConflicts)
|
||||||
|
|
||||||
|
// OnlyOffice blanks
|
||||||
|
protected.Post("/onlyoffice/create", onlyOfficeHandler.CreateBlank)
|
||||||
|
|
||||||
protected.Get("/files/folders-tree", fsHandler.GetFolderTree)
|
protected.Get("/files/folders-tree", fsHandler.GetFolderTree)
|
||||||
|
|
||||||
// File listing and download (with path jail)
|
// File listing and download (with path jail)
|
||||||
|
|
|
||||||
28
frontend/package-lock.json
generated
28
frontend/package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
||||||
"name": "drive-frontend",
|
"name": "drive-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@onlyoffice/document-editor-react": "^2.2.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
|
|
@ -988,6 +989,27 @@
|
||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@onlyoffice/doceditor-types": {
|
||||||
|
"version": "9.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@onlyoffice/doceditor-types/-/doceditor-types-9.3.1.tgz",
|
||||||
|
"integrity": "sha512-2RZEyes+TA/1PbxSGKU8kMQn0upImDgmA3osqvFux+KrUfAW41yB26ZZmA9zKGR57jYiyOuUBtSvFQsXn8C2dw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
|
"node_modules/@onlyoffice/document-editor-react": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@onlyoffice/document-editor-react/-/document-editor-react-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-SPgeJ1lK+I5mpcCUi6Qz98QVtlZR6axxCArgHoKNNBfFqXNmb6iDOf5Z1QUiVQ0xr57oo84D0ptyXZaF8K51nA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "4.18.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@onlyoffice/doceditor-types": ">=9.3.1",
|
||||||
|
"react": "^16.9.0 || ^17 || ^18 || ^19",
|
||||||
|
"react-dom": "^16.9.0 || ^17 || ^18 || ^19"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
|
|
@ -1819,6 +1841,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash._baseiteratee": {
|
"node_modules/lodash._baseiteratee": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash._baseiteratee/-/lodash._baseiteratee-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash._baseiteratee/-/lodash._baseiteratee-4.7.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@onlyoffice/document-editor-react": "^2.2.0",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
MoreVertical, Star, Download, Pencil, Copy, Move,
|
MoreVertical, Star, Download, Pencil, Copy, Move,
|
||||||
FolderPlus, ArrowUp, X, Check, RefreshCw, Info, Link as LinkIcon, Image as ImageIcon, Film as FilmIcon, Home,
|
FolderPlus, ArrowUp, X, Check, RefreshCw, Info, Link as LinkIcon, Image as ImageIcon, Film as FilmIcon, Home,
|
||||||
Sun, Moon, Music as MusicIcon, Code as CodeIcon, Archive as ArchiveIcon, CornerDownRight,
|
Sun, Moon, Music as MusicIcon, Code as CodeIcon, Archive as ArchiveIcon, CornerDownRight,
|
||||||
CheckCircle, AlertCircle, Menu
|
CheckCircle, AlertCircle, Menu, FileText, Presentation
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
import { useIsMobile } from '@/hooks/useIsMobile';
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
|
|
@ -26,6 +26,8 @@ import TaskManager from './TaskManager';
|
||||||
import ShareModal from './ShareModal';
|
import ShareModal from './ShareModal';
|
||||||
import FileInfoModal from './FileInfoModal';
|
import FileInfoModal from './FileInfoModal';
|
||||||
import SharedFilesPage from './SharedFilesPage';
|
import SharedFilesPage from './SharedFilesPage';
|
||||||
|
import OfficeHome from './OfficeHome';
|
||||||
|
import OnlyOfficeEditor from './OnlyOfficeEditor';
|
||||||
|
|
||||||
interface FileItem {
|
interface FileItem {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -237,6 +239,8 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
const [files, setFiles] = useState<FileItem[]>([]);
|
const [files, setFiles] = useState<FileItem[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||||
|
const [appMode, setAppMode] = useState<'drive' | 'docs' | 'slides'>('drive');
|
||||||
|
const [editingFile, setEditingFile] = useState<FileItem | null>(null);
|
||||||
const [activeSection, setActiveSection] = useState<SidebarSection>('files');
|
const [activeSection, setActiveSection] = useState<SidebarSection>('files');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<FileItem[] | null>(null);
|
const [searchResults, setSearchResults] = useState<FileItem[] | null>(null);
|
||||||
|
|
@ -548,8 +552,17 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (file.is_dir) navigateToFolder(file);
|
if (file.is_dir) {
|
||||||
else setPreviewFile(file);
|
navigateToFolder(file);
|
||||||
|
} else if (file.name.endsWith('.docx')) {
|
||||||
|
setAppMode('docs');
|
||||||
|
setEditingFile(file);
|
||||||
|
} else if (file.name.endsWith('.pptx')) {
|
||||||
|
setAppMode('slides');
|
||||||
|
setEditingFile(file);
|
||||||
|
} else {
|
||||||
|
setPreviewFile(file);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1300,21 +1313,53 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
borderColor: 'var(--color-border-subtle)',
|
borderColor: 'var(--color-border-subtle)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3 px-5 py-5">
|
<div className="flex flex-col gap-1 px-3 py-4 border-b" style={{ borderColor: 'var(--color-border-subtle)' }}>
|
||||||
<div
|
<button
|
||||||
className="w-9 h-9 rounded-xl flex items-center justify-center"
|
onClick={() => { setAppMode('drive'); setEditingFile(null); }}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-bold transition-all duration-150"
|
||||||
style={{
|
style={{
|
||||||
background: 'linear-gradient(135deg, var(--color-accent), #8b5cf6)',
|
backgroundColor: appMode === 'drive' ? 'var(--color-accent-subtle)' : 'transparent',
|
||||||
|
color: appMode === 'drive' ? 'var(--color-accent)' : 'var(--color-text-primary)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<HardDrive className="w-4.5 h-4.5 text-white" />
|
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: appMode === 'drive' ? 'linear-gradient(135deg, var(--color-accent), #8b5cf6)' : 'var(--color-bg-elevated)' }}>
|
||||||
|
<HardDrive className={`w-4 h-4 ${appMode === 'drive' ? 'text-white' : ''}`} style={{ color: appMode !== 'drive' ? 'var(--color-text-secondary)' : undefined }} />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-base font-bold" style={{ color: 'var(--color-text-primary)' }}>
|
|
||||||
Drive
|
Drive
|
||||||
</span>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => { setAppMode('docs'); setEditingFile(null); }}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-bold transition-all duration-150"
|
||||||
|
style={{
|
||||||
|
backgroundColor: appMode === 'docs' ? 'var(--color-accent-subtle)' : 'transparent',
|
||||||
|
color: appMode === 'docs' ? 'var(--color-accent)' : 'var(--color-text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: appMode === 'docs' ? 'linear-gradient(135deg, #3b82f6, #2563eb)' : 'var(--color-bg-elevated)' }}>
|
||||||
|
<FileText className={`w-4 h-4 ${appMode === 'docs' ? 'text-white' : ''}`} style={{ color: appMode !== 'docs' ? 'var(--color-text-secondary)' : undefined }} />
|
||||||
|
</div>
|
||||||
|
Docs
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => { setAppMode('slides'); setEditingFile(null); }}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-bold transition-all duration-150"
|
||||||
|
style={{
|
||||||
|
backgroundColor: appMode === 'slides' ? 'var(--color-accent-subtle)' : 'transparent',
|
||||||
|
color: appMode === 'slides' ? 'var(--color-accent)' : 'var(--color-text-primary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-7 h-7 rounded-lg flex items-center justify-center" style={{ background: appMode === 'slides' ? 'linear-gradient(135deg, #f59e0b, #d97706)' : 'var(--color-bg-elevated)' }}>
|
||||||
|
<Presentation className={`w-4 h-4 ${appMode === 'slides' ? 'text-white' : ''}`} style={{ color: appMode !== 'slides' ? 'var(--color-text-secondary)' : undefined }} />
|
||||||
|
</div>
|
||||||
|
Slides
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 px-3 py-2 space-y-1 overflow-y-auto">
|
<nav className="flex-1 px-3 py-2 space-y-1 overflow-y-auto">
|
||||||
|
{appMode === 'drive' && (
|
||||||
|
<>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<button
|
<button
|
||||||
id="nav-files"
|
id="nav-files"
|
||||||
|
|
@ -1454,6 +1499,42 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{appMode === 'docs' && (
|
||||||
|
<>
|
||||||
|
<div className="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500">Documents</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setActiveSection('files'); setEditingFile(null); }}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150"
|
||||||
|
style={{
|
||||||
|
backgroundColor: !editingFile ? 'var(--color-accent-subtle)' : 'transparent',
|
||||||
|
color: !editingFile ? 'var(--color-accent)' : 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText className="w-4.5 h-4.5" />
|
||||||
|
Recent Documents
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{appMode === 'slides' && (
|
||||||
|
<>
|
||||||
|
<div className="px-3 py-2 text-xs font-semibold uppercase tracking-wider text-gray-500">Presentations</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setActiveSection('files'); setEditingFile(null); }}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150"
|
||||||
|
style={{
|
||||||
|
backgroundColor: !editingFile ? 'var(--color-accent-subtle)' : 'transparent',
|
||||||
|
color: !editingFile ? 'var(--color-accent)' : 'var(--color-text-secondary)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Presentation className="w-4.5 h-4.5" />
|
||||||
|
Recent Presentations
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="px-3 py-4 border-t" style={{ borderColor: 'var(--color-border-subtle)' }}>
|
<div className="px-3 py-4 border-t" style={{ borderColor: 'var(--color-border-subtle)' }}>
|
||||||
|
|
@ -1481,6 +1562,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
<header
|
<header
|
||||||
className="flex items-center gap-4 px-6 py-3.5 border-b"
|
className="flex items-center gap-4 px-6 py-3.5 border-b"
|
||||||
style={{
|
style={{
|
||||||
|
display: appMode === 'drive' ? 'flex' : 'none',
|
||||||
backgroundColor: 'var(--color-bg-secondary)',
|
backgroundColor: 'var(--color-bg-secondary)',
|
||||||
borderColor: 'var(--color-border-subtle)',
|
borderColor: 'var(--color-border-subtle)',
|
||||||
}}
|
}}
|
||||||
|
|
@ -1776,6 +1858,18 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
||||||
<StorageDashboard />
|
<StorageDashboard />
|
||||||
) : activeSection === 'shared' ? (
|
) : activeSection === 'shared' ? (
|
||||||
<SharedFilesPage />
|
<SharedFilesPage />
|
||||||
|
) : editingFile ? (
|
||||||
|
<OnlyOfficeEditor
|
||||||
|
file={editingFile}
|
||||||
|
type={editingFile.name.endsWith('.docx') ? 'word' : editingFile.name.endsWith('.pptx') ? 'slide' : 'word'}
|
||||||
|
onClose={() => setEditingFile(null)}
|
||||||
|
/>
|
||||||
|
) : (appMode === 'docs' || appMode === 'slides') && activeSection === 'files' ? (
|
||||||
|
<OfficeHome
|
||||||
|
type={appMode}
|
||||||
|
onFileSelect={(file) => setEditingFile(file)}
|
||||||
|
onCreateBlank={(file) => setEditingFile(file)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
125
frontend/src/components/OfficeHome.tsx
Normal file
125
frontend/src/components/OfficeHome.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Plus, FileText, Presentation } from 'lucide-react';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
|
||||||
|
interface FileItem {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
is_dir: boolean;
|
||||||
|
size: number;
|
||||||
|
mime_type: string;
|
||||||
|
is_pinned: boolean;
|
||||||
|
is_trashed: boolean;
|
||||||
|
mod_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OfficeHomeProps {
|
||||||
|
type: 'docs' | 'slides';
|
||||||
|
onFileSelect: (file: FileItem) => void;
|
||||||
|
onCreateBlank: (file: FileItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OfficeHome({ type, onFileSelect, onCreateBlank }: OfficeHomeProps) {
|
||||||
|
const [recentFiles, setRecentFiles] = useState<FileItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Fetch all files from root directory (as a simple way to find recent files of this type)
|
||||||
|
// Or we could use a search endpoint. For now we will fetch the root directory and filter.
|
||||||
|
api.listFiles('.')
|
||||||
|
.then(files => {
|
||||||
|
const ext = type === 'docs' ? '.docx' : '.pptx';
|
||||||
|
const filtered = files
|
||||||
|
.filter(f => !f.is_dir && f.name.endsWith(ext) && !f.is_trashed)
|
||||||
|
.sort((a, b) => new Date(b.mod_time).getTime() - new Date(a.mod_time).getTime())
|
||||||
|
.slice(0, 10);
|
||||||
|
setRecentFiles(filtered);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [type]);
|
||||||
|
|
||||||
|
const handleCreateBlank = async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.client.post('/onlyoffice/create', { type: type === 'docs' ? 'word' : 'slide' });
|
||||||
|
const { path, name } = res.data;
|
||||||
|
|
||||||
|
const newFile: FileItem = {
|
||||||
|
name,
|
||||||
|
path,
|
||||||
|
is_dir: false,
|
||||||
|
size: 0,
|
||||||
|
mime_type: type === 'docs' ? 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' : 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||||
|
is_pinned: false,
|
||||||
|
is_trashed: false,
|
||||||
|
mod_time: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
onCreateBlank(newFile);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create document", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const title = type === 'docs' ? 'Documents' : 'Presentations';
|
||||||
|
const Icon = type === 'docs' ? FileText : Presentation;
|
||||||
|
const colorClass = type === 'docs' ? 'text-blue-500' : 'text-amber-500';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
||||||
|
{/* Top Banner (Template Gallery placeholder) */}
|
||||||
|
<div className="py-8 px-10" style={{ backgroundColor: 'var(--color-bg-secondary)' }}>
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<h2 className="text-lg font-medium mb-4" style={{ color: 'var(--color-text-primary)' }}>Start a new document</h2>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{/* Blank Document */}
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleCreateBlank}
|
||||||
|
className="w-40 h-52 bg-white rounded-lg border border-gray-200 hover:border-blue-500 transition-colors flex items-center justify-center shadow-sm group"
|
||||||
|
>
|
||||||
|
<Plus className="w-12 h-12 text-gray-300 group-hover:text-blue-500 transition-colors" />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-medium" style={{ color: 'var(--color-text-primary)' }}>Blank</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Documents */}
|
||||||
|
<div className="py-8 px-10">
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<h2 className="text-lg font-medium mb-6" style={{ color: 'var(--color-text-primary)' }}>Recent {title.toLowerCase()}</h2>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-sm text-gray-500">Loading...</div>
|
||||||
|
) : recentFiles.length === 0 ? (
|
||||||
|
<div className="text-sm text-gray-500">No recent {title.toLowerCase()} found.</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-6">
|
||||||
|
{recentFiles.map(file => (
|
||||||
|
<div key={file.path} className="flex flex-col gap-2 group">
|
||||||
|
<button
|
||||||
|
onClick={() => onFileSelect(file)}
|
||||||
|
className="w-full aspect-[1/1.4] bg-white rounded-lg border border-gray-200 group-hover:border-blue-500 transition-colors flex items-center justify-center shadow-sm overflow-hidden"
|
||||||
|
>
|
||||||
|
<Icon className={`w-16 h-16 ${colorClass} opacity-20 group-hover:opacity-100 transition-opacity`} />
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Icon className={`w-4 h-4 flex-shrink-0 ${colorClass}`} />
|
||||||
|
<span className="text-sm font-medium truncate" style={{ color: 'var(--color-text-primary)' }} title={file.name}>
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
frontend/src/components/OnlyOfficeEditor.tsx
Normal file
108
frontend/src/components/OnlyOfficeEditor.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { DocumentEditor } from '@onlyoffice/document-editor-react';
|
||||||
|
import api from '@/lib/api';
|
||||||
|
|
||||||
|
interface FileItem {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
is_dir: boolean;
|
||||||
|
size: number;
|
||||||
|
mime_type: string;
|
||||||
|
is_pinned: boolean;
|
||||||
|
is_trashed: boolean;
|
||||||
|
mod_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnlyOfficeEditorProps {
|
||||||
|
file: FileItem;
|
||||||
|
type: 'word' | 'cell' | 'slide';
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OnlyOfficeEditor({ file, type, onClose }: OnlyOfficeEditorProps) {
|
||||||
|
const [downloadToken, setDownloadToken] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// We need a short-lived download token to pass to the Document Server
|
||||||
|
api.client.get('/auth/download-token')
|
||||||
|
.then(res => setDownloadToken(res.data.token))
|
||||||
|
.catch(console.error);
|
||||||
|
}, [file.path]);
|
||||||
|
|
||||||
|
if (!downloadToken) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
||||||
|
<div className="text-sm" style={{ color: 'var(--color-text-tertiary)' }}>Loading Editor...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080';
|
||||||
|
|
||||||
|
// URL that OnlyOffice will use to download the file
|
||||||
|
const fileUrl = `${API_BASE}/api/files/download/${encodeURIComponent(file.path)}?token=${downloadToken}`;
|
||||||
|
|
||||||
|
// Callback URL that OnlyOffice will post to when saving
|
||||||
|
const callbackUrl = `${API_BASE}/api/public/onlyoffice/callback?path=${encodeURIComponent(file.path)}&token=${downloadToken}`;
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
document: {
|
||||||
|
fileType: file.name.split('.').pop() || 'docx',
|
||||||
|
key: `${file.path}_${new Date(file.mod_time).getTime()}`,
|
||||||
|
title: file.name,
|
||||||
|
url: fileUrl,
|
||||||
|
},
|
||||||
|
documentType: type,
|
||||||
|
editorConfig: {
|
||||||
|
callbackUrl: callbackUrl,
|
||||||
|
mode: 'edit',
|
||||||
|
customization: {
|
||||||
|
forcesave: true,
|
||||||
|
goback: {
|
||||||
|
url: '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDocumentReady = function (event: any) {
|
||||||
|
console.log("Document is loaded");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLoadComponentError = function (errorCode: number, errorDescription: string) {
|
||||||
|
switch (errorCode) {
|
||||||
|
case -1: // Unknown error loading component
|
||||||
|
console.log(errorDescription);
|
||||||
|
break;
|
||||||
|
case -2: // Error load DocsAPI from http://documentserver/
|
||||||
|
console.log(errorDescription);
|
||||||
|
break;
|
||||||
|
case -3: // DocsAPI is not defined
|
||||||
|
console.log(errorDescription);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 w-full h-full flex flex-col" style={{ backgroundColor: '#fff' }}>
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 bg-gray-100 border-b border-gray-200">
|
||||||
|
<span className="font-medium text-sm text-gray-700">{file.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-3 py-1 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded text-sm transition-colors"
|
||||||
|
>
|
||||||
|
Close Document
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<DocumentEditor
|
||||||
|
id="docxEditor"
|
||||||
|
documentServerUrl="http://192.168.50.81:8084/"
|
||||||
|
config={config as any}
|
||||||
|
events_onDocumentReady={onDocumentReady}
|
||||||
|
onLoadComponentError={onLoadComponentError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in a new issue