Fix UI issues and add Pins and Templates
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m38s
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m38s
This commit is contained in:
parent
a361848216
commit
617f8160b2
10 changed files with 281 additions and 59 deletions
7
backend/create_blanks.go
Normal file
7
backend/create_blanks.go
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func main() {
|
||||
fmt.Println("Running...")
|
||||
}
|
||||
|
|
@ -166,6 +166,67 @@ func (h *OnlyOfficeHandler) CreateBlank(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
// POST /api/onlyoffice/template
|
||||
func (h *OnlyOfficeHandler) CreateFromTemplate(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
TemplateName string `json:"template"` // e.g., "apa.docx"
|
||||
}
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "invalid request"})
|
||||
}
|
||||
|
||||
srcPath := filepath.Join("templates", req.TemplateName)
|
||||
if _, err := os.Stat(srcPath); os.IsNotExist(err) {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "template not found"})
|
||||
}
|
||||
|
||||
// Figure out a safe name in root directory
|
||||
baseName := "Untitled Document"
|
||||
ext := ".docx"
|
||||
if strings.HasSuffix(req.TemplateName, ".pptx") {
|
||||
baseName = "Untitled Presentation"
|
||||
ext = ".pptx"
|
||||
}
|
||||
if req.TemplateName == "apa.docx" {
|
||||
baseName = "APA Paper"
|
||||
} else if req.TemplateName == "resume.docx" {
|
||||
baseName = "Resume"
|
||||
}
|
||||
|
||||
finalPath := filepath.Join(h.Config.StorageDir, baseName+ext)
|
||||
counter := 1
|
||||
for {
|
||||
if _, err := os.Stat(finalPath); os.IsNotExist(err) {
|
||||
break
|
||||
}
|
||||
finalPath = filepath.Join(h.Config.StorageDir, fmt.Sprintf("%s (%d)%s", baseName, counter, ext))
|
||||
counter++
|
||||
}
|
||||
|
||||
// Copy the template file
|
||||
srcFile, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "failed to open template"})
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
destFile, err := os.Create(finalPath)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "failed to create destination file"})
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
if _, err := io.Copy(destFile, srcFile); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": "failed to copy template data"})
|
||||
}
|
||||
|
||||
relativePath, _ := filepath.Rel(h.Config.StorageDir, finalPath)
|
||||
return c.JSON(fiber.Map{
|
||||
"path": filepath.ToSlash(relativePath),
|
||||
"name": filepath.Base(finalPath),
|
||||
})
|
||||
}
|
||||
|
||||
// ListOfficeFiles returns all .docx or .pptx files across the entire storage directory.
|
||||
// GET /api/onlyoffice/files?type=docx|pptx
|
||||
func (h *OnlyOfficeHandler) ListOfficeFiles(c *fiber.Ctx) error {
|
||||
|
|
|
|||
|
|
@ -193,6 +193,7 @@ func main() {
|
|||
|
||||
// OnlyOffice
|
||||
protected.Post("/onlyoffice/create", onlyOfficeHandler.CreateBlank)
|
||||
protected.Post("/onlyoffice/template", onlyOfficeHandler.CreateFromTemplate)
|
||||
protected.Post("/onlyoffice/sign", onlyOfficeHandler.SignConfig)
|
||||
protected.Get("/onlyoffice/files", onlyOfficeHandler.ListOfficeFiles)
|
||||
|
||||
|
|
|
|||
7
backend/templates/apa.docx
Normal file
7
backend/templates/apa.docx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<html>
|
||||
<head>
|
||||
<META NAME="robots" CONTENT="noindex,nofollow">
|
||||
<script src="/_Incapsula_Resource?SWJIYLWA=5074a744e2e3d891814e9a2dace20bd4,719d34d31c8e3a6e6fffd425f7e032f3">
|
||||
</script>
|
||||
<body>
|
||||
</body></html>
|
||||
7
backend/templates/resume.docx
Normal file
7
backend/templates/resume.docx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<html>
|
||||
<head>
|
||||
<META NAME="robots" CONTENT="noindex,nofollow">
|
||||
<script src="/_Incapsula_Resource?SWJIYLWA=5074a744e2e3d891814e9a2dace20bd4,719d34d31c8e3a6e6fffd425f7e032f3">
|
||||
</script>
|
||||
<body>
|
||||
</body></html>
|
||||
|
|
@ -1578,7 +1578,7 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
<header
|
||||
className="flex items-center gap-4 px-6 py-3.5 border-b"
|
||||
style={{
|
||||
display: appMode === 'drive' ? 'flex' : 'none',
|
||||
display: editingFile ? 'none' : 'flex',
|
||||
backgroundColor: 'var(--color-bg-secondary)',
|
||||
borderColor: 'var(--color-border-subtle)',
|
||||
}}
|
||||
|
|
@ -1594,24 +1594,30 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
{pathSegments.map((seg, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
{i > 0 && <ChevronRight className="w-3.5 h-3.5" style={{ color: 'var(--color-text-tertiary)' }} />}
|
||||
<button
|
||||
onClick={() => navigateTo(pathSegments.slice(0, i + 1).join('/'))}
|
||||
className={`text-sm font-medium hover:opacity-80 transition-opacity truncate ${isMobile ? 'max-w-[60px]' : 'max-w-[150px]'}`}
|
||||
style={{
|
||||
color: i === pathSegments.length - 1 ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{seg}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{appMode === 'drive' ? (
|
||||
pathSegments.map((seg, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
{i > 0 && <ChevronRight className="w-3.5 h-3.5" style={{ color: 'var(--color-text-tertiary)' }} />}
|
||||
<button
|
||||
onClick={() => navigateTo(pathSegments.slice(0, i + 1).join('/'))}
|
||||
className={`text-sm font-medium hover:opacity-80 transition-opacity truncate ${isMobile ? 'max-w-[60px]' : 'max-w-[150px]'}`}
|
||||
style={{
|
||||
color: i === pathSegments.length - 1 ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
|
||||
}}
|
||||
>
|
||||
{seg}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<span className="text-lg font-semibold" style={{ color: 'var(--color-text-primary)' }}>
|
||||
{appMode === 'docs' ? 'Documents' : 'Presentations'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection !== 'settings' && (
|
||||
{activeSection !== 'settings' && appMode === 'drive' && (
|
||||
<div className={`relative transition-all ${isMobile && mobileSearchOpen ? 'flex-1' : isMobile ? 'w-auto' : 'w-72'}`}>
|
||||
{!isMobile || mobileSearchOpen ? (
|
||||
<>
|
||||
|
|
@ -1799,31 +1805,35 @@ export default function FileExplorer({ onLogout }: FileExplorerProps) {
|
|||
|
||||
<div className="w-px h-5 mx-1" style={{ backgroundColor: 'var(--color-border-subtle)' }} />
|
||||
|
||||
<div className="relative group">
|
||||
<button className="flex items-center gap-1.5 p-2 rounded-lg transition-colors text-sm font-medium" style={{ color: 'var(--color-text-secondary)' }} title="Sort">
|
||||
Sort by: <span className="capitalize">{sortBy}</span>
|
||||
{sortAsc ? <ArrowUp className="w-3 h-3" /> : <ArrowUp className="w-3 h-3 rotate-180 transition-transform" />}
|
||||
</button>
|
||||
<div className="absolute right-0 top-full pt-1 w-40 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all z-50">
|
||||
<div className="w-full rounded-xl shadow-lg border overflow-hidden" style={{ backgroundColor: 'var(--color-bg-elevated)', borderColor: 'var(--color-border)' }}>
|
||||
{['name', 'size', 'date', 'type'].map(opt => (
|
||||
<button key={opt} onClick={() => { if (sortBy === opt) setSortAsc(!sortAsc); else setSortBy(opt as any); }} className="w-full text-left px-4 py-2 text-sm transition-colors hover:bg-white/5 capitalize" style={{ color: sortBy === opt ? 'var(--color-accent)' : 'var(--color-text-primary)' }}>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
{appMode === 'drive' && (
|
||||
<>
|
||||
<div className="relative group">
|
||||
<button className="flex items-center gap-1.5 p-2 rounded-lg transition-colors text-sm font-medium" style={{ color: 'var(--color-text-secondary)' }} title="Sort">
|
||||
Sort by: <span className="capitalize">{sortBy}</span>
|
||||
{sortAsc ? <ArrowUp className="w-3 h-3" /> : <ArrowUp className="w-3 h-3 rotate-180 transition-transform" />}
|
||||
</button>
|
||||
<div className="absolute right-0 top-full pt-1 w-40 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all z-50">
|
||||
<div className="w-full rounded-xl shadow-lg border overflow-hidden" style={{ backgroundColor: 'var(--color-bg-elevated)', borderColor: 'var(--color-border)' }}>
|
||||
{['name', 'size', 'date', 'type'].map(opt => (
|
||||
<button key={opt} onClick={() => { if (sortBy === opt) setSortAsc(!sortAsc); else setSortBy(opt as any); }} className="w-full text-left px-4 py-2 text-sm transition-colors hover:bg-white/5 capitalize" style={{ color: sortBy === opt ? 'var(--color-accent)' : 'var(--color-text-primary)' }}>
|
||||
{opt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
id="view-toggle"
|
||||
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
|
||||
className="p-2 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
title={viewMode === 'grid' ? 'Switch to list view' : 'Switch to grid view'}
|
||||
>
|
||||
{viewMode === 'grid' ? <List className="w-4.5 h-4.5" /> : <Grid3X3 className="w-4.5 h-4.5" />}
|
||||
</button>
|
||||
<button
|
||||
id="view-toggle"
|
||||
onClick={() => setViewMode(viewMode === 'grid' ? 'list' : 'grid')}
|
||||
className="p-2 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--color-text-secondary)' }}
|
||||
title={viewMode === 'grid' ? 'Switch to list view' : 'Switch to grid view'}
|
||||
>
|
||||
{viewMode === 'grid' ? <List className="w-4.5 h-4.5" /> : <Grid3X3 className="w-4.5 h-4.5" />}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeSection === 'files' && (
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, Pin } from 'lucide-react';
|
||||
import api from '@/lib/api';
|
||||
|
||||
interface FileItem {
|
||||
|
|
@ -45,6 +45,7 @@ interface ContextMenuState {
|
|||
|
||||
export default function OfficeHome({ type, onFileSelect, onCreateBlank }: OfficeHomeProps) {
|
||||
const [recentFiles, setRecentFiles] = useState<FileItem[]>([]);
|
||||
const [pinnedFiles, setPinnedFiles] = useState<FileItem[]>([]);
|
||||
const [allFiles, setAllFiles] = useState<FileItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingAll, setLoadingAll] = useState(true);
|
||||
|
|
@ -64,7 +65,7 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank }: Office
|
|||
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);
|
||||
.slice(0, 12);
|
||||
setRecentFiles(filtered);
|
||||
setLoading(false);
|
||||
})
|
||||
|
|
@ -73,6 +74,17 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank }: Office
|
|||
setLoading(false);
|
||||
});
|
||||
|
||||
// Fetch pinned files
|
||||
api.listPinned()
|
||||
.then(data => {
|
||||
const files: FileItem[] = data || [];
|
||||
const filtered = files
|
||||
.filter(f => f.name.endsWith(ext) && !f.is_trashed)
|
||||
.sort((a, b) => new Date(b.mod_time).getTime() - new Date(a.mod_time).getTime());
|
||||
setPinnedFiles(filtered);
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
// Fetch ALL office files recursively
|
||||
api.listOfficeFiles(fileTypeParam)
|
||||
.then(data => {
|
||||
|
|
@ -145,14 +157,34 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank }: Office
|
|||
setContextMenu({ x: e.clientX, y: e.clientY, file });
|
||||
};
|
||||
|
||||
const handlePin = async (file: FileItem) => {
|
||||
try {
|
||||
await api.togglePin(file.path);
|
||||
fetchFiles();
|
||||
} catch (err) {
|
||||
console.error("Failed to pin", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!renaming || !renameValue.trim()) {
|
||||
let trimmed = renameValue.trim();
|
||||
if (!renaming || !trimmed) {
|
||||
setRenaming(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine the current file's extension to append
|
||||
const currentFile = recentFiles.find(f => f.path === renaming) || allFiles.find(f => f.path === renaming);
|
||||
if (currentFile) {
|
||||
const extMatch = currentFile.name.match(/\.[^.]+$/);
|
||||
const currentExt = extMatch ? extMatch[0] : '';
|
||||
if (!trimmed.toLowerCase().endsWith(currentExt.toLowerCase())) {
|
||||
trimmed += currentExt;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await api.rename(renaming, renameValue);
|
||||
await api.rename(renaming, trimmed);
|
||||
setRenaming(null);
|
||||
fetchFiles(); // Refresh
|
||||
} catch (err) {
|
||||
|
|
@ -176,13 +208,15 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank }: Office
|
|||
|
||||
const renderFileCard = (file: FileItem) => {
|
||||
const isRenaming = renaming === file.path;
|
||||
const extMatch = file.name.match(/\.[^.]+$/);
|
||||
const baseName = extMatch ? file.name.slice(0, -extMatch[0].length) : file.name;
|
||||
|
||||
return (
|
||||
<div key={file.path} className="flex flex-col gap-2 group">
|
||||
<button
|
||||
onClick={() => !isRenaming && onFileSelect(file)}
|
||||
onContextMenu={(e) => handleContextMenu(e, file)}
|
||||
className="w-full aspect-[1/1.4] bg-white rounded-lg border border-gray-200 group-hover:border-blue-400 transition-colors flex items-center justify-center shadow-sm overflow-hidden relative"
|
||||
className="w-full aspect-[1/1.4] bg-white dark:bg-[#1e1e1e] rounded-lg border border-gray-200 dark:border-gray-800 group-hover:border-blue-400 dark:group-hover:border-blue-500 transition-colors flex items-center justify-center shadow-sm overflow-hidden relative"
|
||||
>
|
||||
<Icon className="w-16 h-16 text-3xl opacity-30 group-hover:opacity-60 transition-opacity" />
|
||||
</button>
|
||||
|
|
@ -207,7 +241,7 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank }: Office
|
|||
style={{ color: 'var(--color-text-primary)', wordBreak: 'break-word' }}
|
||||
title={file.name}
|
||||
>
|
||||
{file.name}
|
||||
{baseName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -225,16 +259,73 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank }: Office
|
|||
<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"
|
||||
className="w-40 h-52 bg-white dark:bg-[#1e1e1e] rounded-lg border border-gray-200 dark:border-gray-800 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>
|
||||
{type === 'docs' && (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await api.createFromTemplate('apa.docx');
|
||||
const newFile: FileItem = { name: res.name, path: res.path, is_dir: false, size: 0, mime_type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', is_pinned: false, is_trashed: false, mod_time: new Date().toISOString() };
|
||||
onCreateBlank(newFile);
|
||||
} catch (err) { console.error("Failed to create template", err); }
|
||||
}}
|
||||
className="w-40 h-52 bg-white dark:bg-[#1e1e1e] rounded-lg border border-gray-200 dark:border-gray-800 hover:border-blue-500 transition-colors flex items-center justify-center shadow-sm relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-16 bg-blue-50 border-b border-blue-100 flex items-center px-4">
|
||||
<div className="w-1/2 h-2 bg-blue-200 rounded"></div>
|
||||
</div>
|
||||
<div className="w-full h-full pt-20 px-4 flex flex-col gap-2">
|
||||
<div className="w-full h-1.5 bg-gray-100 rounded"></div>
|
||||
<div className="w-full h-1.5 bg-gray-100 rounded"></div>
|
||||
<div className="w-3/4 h-1.5 bg-gray-100 rounded"></div>
|
||||
</div>
|
||||
</button>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--color-text-primary)' }}>APA Paper</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
onClick={async () => {
|
||||
try {
|
||||
const res = await api.createFromTemplate('resume.docx');
|
||||
const newFile: FileItem = { name: res.name, path: res.path, is_dir: false, size: 0, mime_type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', is_pinned: false, is_trashed: false, mod_time: new Date().toISOString() };
|
||||
onCreateBlank(newFile);
|
||||
} catch (err) { console.error("Failed to create template", err); }
|
||||
}}
|
||||
className="w-40 h-52 bg-white dark:bg-[#1e1e1e] rounded-lg border border-gray-200 dark:border-gray-800 hover:border-blue-500 transition-colors flex flex-col items-center justify-center shadow-sm relative overflow-hidden p-4"
|
||||
>
|
||||
<div className="w-12 h-12 bg-gray-200 rounded-full mb-4"></div>
|
||||
<div className="w-full h-1.5 bg-gray-200 rounded mb-2"></div>
|
||||
<div className="w-2/3 h-1.5 bg-gray-200 rounded mb-4"></div>
|
||||
<div className="w-full h-1.5 bg-gray-100 rounded mb-1"></div>
|
||||
<div className="w-full h-1.5 bg-gray-100 rounded mb-1"></div>
|
||||
</button>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--color-text-primary)' }}>Resume</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pinned Documents */}
|
||||
{pinnedFiles.length > 0 && (
|
||||
<div className="py-8 px-10 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h2 className="text-lg font-medium mb-6" style={{ color: 'var(--color-text-primary)' }}>Pinned {title.toLowerCase()}</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-6">
|
||||
{pinnedFiles.map(file => renderFileCard(file))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Documents */}
|
||||
<div className="py-8 px-10">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
|
|
@ -285,8 +376,21 @@ export default function OfficeHome({ type, onFileSelect, onCreateBlank }: Office
|
|||
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-black/5 transition-colors"
|
||||
style={{ color: 'var(--color-text-primary)' }}
|
||||
onClick={() => {
|
||||
handlePin(contextMenu.file);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
<Pin className="w-4 h-4" style={{ color: 'var(--color-text-secondary)' }} />
|
||||
{contextMenu.file.is_pinned || pinnedFiles.find(f => f.path === contextMenu.file.path) ? 'Unpin' : 'Pin'}
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-4 py-2 text-left text-sm flex items-center gap-3 hover:bg-black/5 transition-colors"
|
||||
style={{ color: 'var(--color-text-primary)' }}
|
||||
onClick={() => {
|
||||
const extMatch = contextMenu.file.name.match(/\.[^.]+$/);
|
||||
const baseName = extMatch ? contextMenu.file.name.slice(0, -extMatch[0].length) : contextMenu.file.name;
|
||||
setRenaming(contextMenu.file.path);
|
||||
setRenameValue(contextMenu.file.name);
|
||||
setRenameValue(baseName);
|
||||
setContextMenu(null);
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -25,16 +25,33 @@ export default function OnlyOfficeEditor({ file, type, onClose, onRename }: Only
|
|||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [config, setConfig] = useState<any>(null);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState(file.name);
|
||||
|
||||
const extMatch = file.name.match(/\.[^.]+$/);
|
||||
const ext = extMatch ? extMatch[0] : '';
|
||||
const baseName = extMatch ? file.name.slice(0, -ext.length) : file.name;
|
||||
|
||||
const [renameValue, setRenameValue] = useState(baseName);
|
||||
const renameInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleRename = async () => {
|
||||
const trimmed = renameValue.trim();
|
||||
if (!trimmed || trimmed === file.name) {
|
||||
setRenameValue(file.name);
|
||||
let trimmed = renameValue.trim();
|
||||
if (!trimmed) {
|
||||
setRenameValue(baseName);
|
||||
setIsRenaming(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-append extension if the user didn't type it
|
||||
if (!trimmed.toLowerCase().endsWith(ext.toLowerCase())) {
|
||||
trimmed += ext;
|
||||
}
|
||||
|
||||
if (trimmed === file.name) {
|
||||
setRenameValue(baseName);
|
||||
setIsRenaming(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.rename(file.path, trimmed);
|
||||
// Build new path from the response or derive it
|
||||
|
|
@ -44,7 +61,7 @@ export default function OnlyOfficeEditor({ file, type, onClose, onRename }: Only
|
|||
setIsRenaming(false);
|
||||
} catch (err) {
|
||||
console.error('Failed to rename', err);
|
||||
setRenameValue(file.name);
|
||||
setRenameValue(baseName);
|
||||
setIsRenaming(false);
|
||||
}
|
||||
};
|
||||
|
|
@ -139,28 +156,27 @@ export default function OnlyOfficeEditor({ file, type, onClose, onRename }: Only
|
|||
onBlur={handleRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRename();
|
||||
if (e.key === 'Escape') { setRenameValue(file.name); setIsRenaming(false); }
|
||||
if (e.key === 'Escape') { setRenameValue(baseName); setIsRenaming(false); }
|
||||
}}
|
||||
className="font-medium text-sm text-gray-700 bg-white border border-blue-400 rounded px-2 py-0.5 outline-none min-w-[200px]"
|
||||
className="font-medium text-sm text-gray-700 bg-white border border-blue-400 rounded px-2 py-0.5 outline-none w-1/3 min-w-[300px] max-w-[600px]"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsRenaming(true);
|
||||
setRenameValue(file.name);
|
||||
setRenameValue(baseName);
|
||||
setTimeout(() => {
|
||||
if (renameInputRef.current) {
|
||||
renameInputRef.current.focus();
|
||||
const dotIdx = file.name.lastIndexOf('.');
|
||||
renameInputRef.current.setSelectionRange(0, dotIdx > 0 ? dotIdx : file.name.length);
|
||||
renameInputRef.current.select();
|
||||
}
|
||||
}, 50);
|
||||
}}
|
||||
className="font-medium text-sm text-gray-700 hover:text-blue-600 hover:underline cursor-pointer transition-colors"
|
||||
className="font-medium text-sm text-gray-700 hover:text-blue-600 hover:underline cursor-pointer transition-colors truncate max-w-[500px]"
|
||||
title="Click to rename"
|
||||
>
|
||||
{file.name}
|
||||
{baseName}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
|
|
|||
|
|
@ -478,6 +478,14 @@ class ApiClient {
|
|||
return res.json();
|
||||
}
|
||||
|
||||
async createFromTemplate(templateName: string) {
|
||||
const res = await this.request('/api/onlyoffice/template', {
|
||||
method: 'POST',
|
||||
body: { template: templateName }
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async signOnlyOfficeConfig(config: any) {
|
||||
const res = await this.request('/api/onlyoffice/sign', {
|
||||
method: 'POST',
|
||||
|
|
|
|||
1
frontend/tsconfig.tsbuildinfo
Normal file
1
frontend/tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Reference in a new issue