File transfer

This commit is contained in:
2026-06-11 20:33:44 -05:00
parent 2cb1d58264
commit b362357bcb
58 changed files with 5096 additions and 4 deletions

View File

@@ -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>
);
}

View File

@@ -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)}
/>
)}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}