File transfer
This commit is contained in:
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Diff Viewer — unified diff with syntax coloring
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import http from '@/api/http';
|
||||
|
||||
interface DiffLine {
|
||||
type: 'context' | 'added' | 'removed' | 'header';
|
||||
content: string;
|
||||
line_old?: number;
|
||||
line_new?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
serverUuid: string;
|
||||
revision: { id: number; revision_number: number };
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function DiffViewer({ serverUuid, revision, onClose }: Props) {
|
||||
const [lines, setLines] = useState<DiffLine[]>([]);
|
||||
const [binary, setBinary] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
http.get(`/api/client/servers/${serverUuid}/addons/files/revisions/${revision.id}/diff`)
|
||||
.then(({ data }) => {
|
||||
setBinary(data.binary);
|
||||
setLines(data.lines ?? []);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [serverUuid, revision.id]);
|
||||
|
||||
return (
|
||||
<div className='addon-modal-overlay' onClick={onClose} role='dialog' aria-modal='true'>
|
||||
<div className='addon-modal addon-modal-wide' onClick={e => e.stopPropagation()}>
|
||||
<div className='addon-modal-header'>
|
||||
<span>Diff — Revision #{revision.revision_number}</span>
|
||||
<button className='addon-modal-close' onClick={onClose} aria-label='Close'>✕</button>
|
||||
</div>
|
||||
{loading && <div className='addon-loading'>Loading diff…</div>}
|
||||
{!loading && binary && <div className='addon-empty'>Binary file — diff not available.</div>}
|
||||
{!loading && !binary && (
|
||||
<pre className='addon-diff'>
|
||||
{lines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`addon-diff-line addon-diff-${line.type}`}
|
||||
>
|
||||
<span className='addon-diff-lnum'>
|
||||
{line.line_old ?? ' '} {line.line_new ?? ' '}
|
||||
</span>
|
||||
<span className='addon-diff-sign'>
|
||||
{line.type === 'added' ? '+' : line.type === 'removed' ? '−' : ' '}
|
||||
</span>
|
||||
{line.content}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* File History Button — injected into the file editor toolbar
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import RevisionSidebar from './RevisionSidebar';
|
||||
|
||||
interface Props { filePath: string; }
|
||||
|
||||
export default function FileHistoryButton({ filePath }: Props) {
|
||||
const { id: serverUuid } = useParams<{ id: string }>();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
className='addon-btn addon-btn-sm addon-btn-history'
|
||||
onClick={() => setOpen(true)}
|
||||
title='View file revision history'
|
||||
aria-label='View file revision history'
|
||||
>
|
||||
🕐 History
|
||||
</button>
|
||||
{open && serverUuid && (
|
||||
<RevisionSidebar
|
||||
serverUuid={serverUuid}
|
||||
filePath={filePath}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Restore Modal — confirmation before overwriting current file
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import http from '@/api/http';
|
||||
|
||||
interface Props {
|
||||
serverUuid: string;
|
||||
revision: { id: number; revision_number: number; change_summary: string };
|
||||
onClose: () => void;
|
||||
onRestored: () => void;
|
||||
}
|
||||
|
||||
export default function RestoreModal({ serverUuid, revision, onClose, onRestored }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleRestore = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await http.post(`/api/client/servers/${serverUuid}/addons/files/revisions/${revision.id}/restore`);
|
||||
onRestored();
|
||||
} catch (e: any) {
|
||||
setError(e?.response?.data?.errors?.[0]?.detail ?? 'Restore failed.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='addon-modal-overlay' role='dialog' aria-modal='true'>
|
||||
<div className='addon-modal'>
|
||||
<div className='addon-modal-icon'>🔄</div>
|
||||
<h3 className='addon-modal-title'>Restore Revision #{revision.revision_number}?</h3>
|
||||
<p className='addon-modal-body'>
|
||||
This will overwrite the current file with revision #{revision.revision_number}.<br />
|
||||
<em>{revision.change_summary}</em>
|
||||
</p>
|
||||
{error && <p className='addon-error'>{error}</p>}
|
||||
<div className='addon-modal-actions'>
|
||||
<button className='addon-btn addon-btn-secondary' onClick={onClose} disabled={loading}>Cancel</button>
|
||||
<button className='addon-btn addon-btn-primary' onClick={handleRestore} disabled={loading}>
|
||||
{loading ? 'Restoring…' : 'Restore'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Revision Sidebar — slide-in panel listing file revisions
|
||||
* Ball Studios <https://git.balls.studio>
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import http from '@/api/http';
|
||||
import DiffViewer from './DiffViewer';
|
||||
import RestoreModal from './RestoreModal';
|
||||
|
||||
interface Revision {
|
||||
id: number;
|
||||
revision_number: number;
|
||||
size_bytes: number;
|
||||
change_summary: string;
|
||||
created_at: string;
|
||||
author: { username: string } | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
serverUuid: string;
|
||||
filePath: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function RevisionSidebar({ serverUuid, filePath, onClose }: Props) {
|
||||
const [revisions, setRevisions] = useState<Revision[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [viewing, setViewing] = useState<Revision | null>(null);
|
||||
const [restoring, setRestoring] = useState<Revision | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
http.get(`/api/client/servers/${serverUuid}/addons/files/revisions`, {
|
||||
params: { path: filePath },
|
||||
})
|
||||
.then(({ data }) => setRevisions(data.data ?? []))
|
||||
.finally(() => setLoading(false));
|
||||
}, [serverUuid, filePath]);
|
||||
|
||||
return (
|
||||
<div className='addon-sidebar-overlay' onClick={onClose} aria-modal='true' role='dialog'>
|
||||
<div className='addon-sidebar' onClick={e => e.stopPropagation()}>
|
||||
<div className='addon-sidebar-header'>
|
||||
<span>🕐 File History — <code>{filePath}</code></span>
|
||||
<button className='addon-sidebar-close' onClick={onClose} aria-label='Close'>✕</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className='addon-loading'>Loading revisions…</div>}
|
||||
|
||||
{!loading && revisions.length === 0 && (
|
||||
<div className='addon-empty'>No revisions recorded for this file yet.</div>
|
||||
)}
|
||||
|
||||
<ul className='addon-revision-list'>
|
||||
{revisions.map(rev => (
|
||||
<li key={rev.id} className='addon-revision-item'>
|
||||
<div className='addon-revision-meta'>
|
||||
<span className='addon-revision-num'>#{rev.revision_number}</span>
|
||||
<span className='addon-revision-author'>{rev.author?.username ?? 'Unknown'}</span>
|
||||
<span className='addon-revision-date'>{new Date(rev.created_at).toLocaleString()}</span>
|
||||
<span className='addon-revision-size'>{(rev.size_bytes / 1024).toFixed(1)} KB</span>
|
||||
</div>
|
||||
<p className='addon-revision-summary'>{rev.change_summary}</p>
|
||||
<div className='addon-revision-actions'>
|
||||
<button className='addon-btn addon-btn-sm' onClick={() => setViewing(rev)}>View Diff</button>
|
||||
<button className='addon-btn addon-btn-sm addon-btn-primary' onClick={() => setRestoring(rev)}>Restore</button>
|
||||
<a
|
||||
className='addon-btn addon-btn-sm'
|
||||
href={`/api/client/servers/${serverUuid}/addons/files/revisions/${rev.id}/download`}
|
||||
download
|
||||
>Download</a>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{viewing && (
|
||||
<DiffViewer
|
||||
serverUuid={serverUuid}
|
||||
revision={viewing}
|
||||
onClose={() => setViewing(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{restoring && (
|
||||
<RestoreModal
|
||||
serverUuid={serverUuid}
|
||||
revision={restoring}
|
||||
onClose={() => setRestoring(null)}
|
||||
onRestored={() => { setRestoring(null); onClose(); }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user