feat: file view
This commit is contained in:
parent
28e3a75593
commit
34880f4abc
12 changed files with 394 additions and 25 deletions
|
@ -3,6 +3,7 @@ package routes
|
|||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.wh64.net/devproje/kuma-archive/internal/service"
|
||||
"github.com/gin-contrib/static"
|
||||
|
@ -14,22 +15,28 @@ func New(app *gin.Engine, apiOnly bool) {
|
|||
{
|
||||
api.GET("/path/*path", func(ctx *gin.Context) {
|
||||
worker := service.NewWorkerService()
|
||||
|
||||
path := ctx.Param("path")
|
||||
data, err := worker.Read(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
_, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
ctx.Status(404)
|
||||
return
|
||||
}
|
||||
|
||||
if !data.IsDir {
|
||||
ctx.FileAttachment(data.Path, data.Name)
|
||||
ctx.JSON(200, gin.H{
|
||||
"ok": 1,
|
||||
"path": path,
|
||||
"total": data.FileSize,
|
||||
"is_dir": false,
|
||||
"entries": nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
raw, err := os.ReadDir(data.Path)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
ctx.Status(500)
|
||||
return
|
||||
}
|
||||
|
@ -44,7 +51,8 @@ func New(app *gin.Engine, apiOnly bool) {
|
|||
|
||||
entries = append(entries, service.DirEntry{
|
||||
Name: entry.Name(),
|
||||
Path: path,
|
||||
Path: filepath.Join(path, entry.Name()),
|
||||
Date: finfo.ModTime().Unix(),
|
||||
FileSize: uint64(finfo.Size()),
|
||||
IsDir: finfo.IsDir(),
|
||||
})
|
||||
|
@ -53,9 +61,47 @@ func New(app *gin.Engine, apiOnly bool) {
|
|||
ctx.JSON(200, gin.H{
|
||||
"ok": 1,
|
||||
"path": path,
|
||||
"total": data.FileSize,
|
||||
"is_dir": true,
|
||||
"entries": entries,
|
||||
})
|
||||
})
|
||||
|
||||
api.GET("/raw/*path", func(ctx *gin.Context) {
|
||||
worker := service.NewWorkerService()
|
||||
path := ctx.Param("path")
|
||||
data, err := worker.Read(path)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
ctx.Status(404)
|
||||
return
|
||||
}
|
||||
|
||||
if data.IsDir {
|
||||
ctx.String(400, "current path is not file")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.File(data.Path)
|
||||
})
|
||||
|
||||
api.GET("/download/*path", func(ctx *gin.Context) {
|
||||
worker := service.NewWorkerService()
|
||||
path := ctx.Param("path")
|
||||
data, err := worker.Read(path)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
ctx.Status(404)
|
||||
return
|
||||
}
|
||||
|
||||
if data.IsDir {
|
||||
ctx.String(400, "current path is not file")
|
||||
return
|
||||
}
|
||||
|
||||
ctx.FileAttachment(data.Path, data.Name)
|
||||
})
|
||||
}
|
||||
|
||||
if apiOnly {
|
||||
|
|
|
@ -12,6 +12,7 @@ type WorkerService struct{}
|
|||
type DirEntry struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Date int64 `json:"date"`
|
||||
FileSize uint64 `json:"file_size"`
|
||||
IsDir bool `json:"is_dir"`
|
||||
}
|
||||
|
@ -29,8 +30,10 @@ func (sv *WorkerService) Read(path string) (*DirEntry, error) {
|
|||
|
||||
ret := DirEntry{
|
||||
Name: info.Name(),
|
||||
Date: info.ModTime().Unix(),
|
||||
Path: fullpath,
|
||||
FileSize: uint64(info.Size()),
|
||||
IsDir: info.IsDir(),
|
||||
}
|
||||
return &ret, nil
|
||||
}
|
||||
|
|
27
src/App.scss
27
src/App.scss
|
@ -43,17 +43,40 @@
|
|||
|
||||
.ka-menu-item {
|
||||
width: 100%;
|
||||
height: 55px;
|
||||
padding: 5px 0.5rem;
|
||||
height: 45px;
|
||||
display: flex;
|
||||
padding: 0 0.5rem;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--nav-hover);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: var(--focus);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.ka-footer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
|
73
src/App.tsx
73
src/App.tsx
|
@ -1,10 +1,12 @@
|
|||
import { BrowserRouter, Route, Routes, useLocation } from "react-router";
|
||||
import { usePath } from "./store/path";
|
||||
import { useEffect, useState } from "react";
|
||||
import Directory from "./components/directory";
|
||||
import { DirEntry, usePath } from "./store/path";
|
||||
import { DynamicIcon, IconName } from "lucide-react/dynamic";
|
||||
import { BrowserRouter, Route, Routes, useLocation } from "react-router";
|
||||
|
||||
import "./App.scss";
|
||||
import kuma from "./assets/kuma.png";
|
||||
import { DynamicIcon, IconName } from "lucide-react/dynamic";
|
||||
import FileView from "./components/file-view";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
|
@ -24,19 +26,37 @@ function Dashboard() {
|
|||
useEffect(() => {
|
||||
if (!load) {
|
||||
path.update(location.pathname.substring(1, location.pathname.length));
|
||||
setLoad(true);
|
||||
}
|
||||
setLoad(true);
|
||||
|
||||
const id = setInterval(() => {
|
||||
path.update(location.pathname.substring(1, location.pathname.length));
|
||||
}, 5000);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = setInterval(() => {
|
||||
path.update(location.pathname.substring(1, location.pathname.length));
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(id);
|
||||
}, [load, path, location]);
|
||||
|
||||
if (!load) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container-md ka-view">
|
||||
<Header />
|
||||
{typeof path.data !== "undefined" ? path.data.is_dir ? <Directory /> : <FileView /> : (
|
||||
<>
|
||||
<h1>404 Not Found</h1>
|
||||
|
||||
<button className="primary" onClick={ev => {
|
||||
ev.preventDefault();
|
||||
document.location.href = "/";
|
||||
}}>Back to home</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
@ -46,7 +66,7 @@ function Header() {
|
|||
|
||||
return (
|
||||
<nav className="ka-nav">
|
||||
<a className="title">
|
||||
<a className="title" href="/">
|
||||
<img src={kuma} alt="" />
|
||||
<h4 className="title-content">Kuma Archive</h4>
|
||||
</a>
|
||||
|
@ -77,16 +97,47 @@ function MenuItem({ icon, name, block }: { icon: IconName, name: string, block?:
|
|||
return (
|
||||
<a className={"ka-menu-item link"} onClick={ev => {
|
||||
ev.preventDefault();
|
||||
|
||||
if (typeof block === "undefined")
|
||||
return;
|
||||
|
||||
block();
|
||||
}}>
|
||||
<DynamicIcon name={icon} className="link" />
|
||||
<DynamicIcon name={icon} />
|
||||
<span>{name}</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer() {
|
||||
const path = usePath();
|
||||
let file = 0;
|
||||
let dir = 0;
|
||||
|
||||
if (typeof path.data !== "undefined") {
|
||||
if (path.data.is_dir) {
|
||||
path.data.entries.forEach((entry: DirEntry) => {
|
||||
if (entry.is_dir) {
|
||||
dir += 1;
|
||||
} else {
|
||||
file += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<footer className="ka-footer">
|
||||
{path.data ? path.data.is_dir ? (
|
||||
<div className="searched">
|
||||
Found {dir === 1 ? `${dir} directory` : `${dir} directories`}, {file === 1 ? `${file} file` : `${file} files`}
|
||||
</div>
|
||||
) : <></> : <></>}
|
||||
|
||||
<div className="footer">
|
||||
© 2020-2025 Project_IO. MIT License. Powered by WSERVER.
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
|
@ -1 +1,30 @@
|
|||
.ka-dir {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
margin: 10px 0;
|
||||
flex-direction: column;
|
||||
border-bottom: solid 2px var(--foreground);
|
||||
|
||||
.ka-dir-top {
|
||||
border-bottom: solid 2px var(--foreground);
|
||||
}
|
||||
|
||||
.ka-dir-row {
|
||||
padding: 0 10px;
|
||||
width: 100%;
|
||||
height: 25px;
|
||||
display: grid;
|
||||
grid-template-columns: 0.1fr 2fr 1fr 1fr;
|
||||
|
||||
.ka-dir-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
}
|
||||
|
||||
a:hover.ka-dir-item {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,68 @@
|
|||
import { convert } from "../../util/unit";
|
||||
import { DynamicIcon } from "lucide-react/dynamic";
|
||||
import { DirEntry, usePath } from "../../store/path";
|
||||
|
||||
import "./directory.scss";
|
||||
|
||||
function Directory() {
|
||||
const path = usePath();
|
||||
if (typeof path.data === "undefined")
|
||||
return <></>;
|
||||
|
||||
return (
|
||||
<></>
|
||||
<div className="ka-dir">
|
||||
<div className="ka-dir-row ka-dir-top">
|
||||
<div className="ka-dir-item"></div>
|
||||
<b className="ka-dir-item">Name</b>
|
||||
<b className="ka-dir-item">Size</b>
|
||||
<b className="ka-dir-item">Date</b>
|
||||
</div>
|
||||
|
||||
{path.data.path === "/" ? <></> : (
|
||||
<DirItem data={{
|
||||
name: "../",
|
||||
path: path.data.path.endsWith("/") ? path.data.path += ".." : path.data.path += "/..",
|
||||
date: -1,
|
||||
file_size: -1,
|
||||
is_dir: true,
|
||||
}} />
|
||||
)}
|
||||
|
||||
{path.data.entries.map((entry, key) => {
|
||||
return <DirItem data={entry} key={key} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DirItem({ data }: { data: DirEntry }) {
|
||||
return (
|
||||
<div className="ka-dir-row">
|
||||
<div className="ka-dir-item">
|
||||
{data.is_dir ? (
|
||||
<DynamicIcon name="folder" size={18} />
|
||||
) : <></>}
|
||||
</div>
|
||||
<a className="ka-dir-item" href={data.path}>
|
||||
{data.name}
|
||||
</a>
|
||||
<span className="ka-dir-item">
|
||||
{data.is_dir ? (
|
||||
"-"
|
||||
): convert(data.file_size)}
|
||||
</span>
|
||||
<span className="ka-dir-item">
|
||||
{data.date === -1 ? "-" : new Date(data.date * 1000).toLocaleString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
}).replace(/,/g, "")}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
.ka-fileview {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
margin: 1rem 0;
|
||||
min-height: 300px;
|
||||
border-radius: 15px;
|
||||
flex-direction: column;
|
||||
background-color: var(--nav-color);
|
||||
border: 1px solid var(--foreground);
|
||||
|
||||
.title {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
padding: 5px 10px;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
border-radius: 15px 15px 0 0;
|
||||
justify-content: space-between;
|
||||
background-color: var(--nav-hover);
|
||||
border-bottom: 1px solid var(--foreground);
|
||||
|
||||
.name {
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
|
||||
span {
|
||||
margin: 0 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-row {
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.btn {
|
||||
padding: 0;
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pre, code {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
border-radius: 0 0 15px 15px;
|
||||
}
|
||||
}
|
|
@ -1,6 +1,63 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useRaw } from "../../store/raw";
|
||||
import "./fview.scss";
|
||||
import { useLocation } from "react-router";
|
||||
import { DynamicIcon } from "lucide-react/dynamic";
|
||||
|
||||
function FileView() {
|
||||
const raw = useRaw();
|
||||
const location = useLocation();
|
||||
const [load, setLoad] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!load) {
|
||||
raw.update(location.pathname.substring(1, location.pathname.length));
|
||||
setLoad(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const id = setInterval(() => {
|
||||
raw.update(location.pathname.substring(1, location.pathname.length));
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(id);
|
||||
}, [raw, location, load]);
|
||||
|
||||
return (
|
||||
<></>
|
||||
<div className="ka-fileview">
|
||||
<span className="title">
|
||||
<div className="name">
|
||||
<a className="link" href={location.pathname.endsWith("/") ? location.pathname + ".." : location.pathname + "/.."}>
|
||||
<DynamicIcon name="chevron-left" />
|
||||
</a>
|
||||
<span>{location.pathname}</span>
|
||||
</div>
|
||||
<div className="action-row">
|
||||
<a className="btn link" onClick={ev => {
|
||||
ev.preventDefault();
|
||||
fetch(`/api/download${location.pathname}`)
|
||||
.then(response => response.blob())
|
||||
.then(blob => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
|
||||
a.style.display = "none";
|
||||
a.href = url;
|
||||
a.download = location.pathname.split("/").pop() || "download";
|
||||
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
|
||||
window.URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch(error => console.error("Download failed:", error));
|
||||
}}>
|
||||
<DynamicIcon name="download" />
|
||||
</a>
|
||||
</div>
|
||||
</span>
|
||||
<pre>{raw.data}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css");
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap');
|
||||
|
||||
:root {
|
||||
--background: #242424;
|
||||
|
@ -31,6 +32,7 @@
|
|||
--btn-danger-focus: #a72532;
|
||||
|
||||
--font-family: "Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, "Helvetica Neue", "Segoe UI", "Apple SD Gothic Neo", "Noto Sans KR", "Malgun Gothic", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", sans-serif;
|
||||
--font-mono: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
html, body {
|
||||
|
@ -184,3 +186,7 @@ input, button {
|
|||
border-radius: 25px;
|
||||
padding: 0.25rem 15px;
|
||||
}
|
||||
|
||||
code, pre {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
|
|
@ -1,19 +1,24 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
interface PathState {
|
||||
data: PathResponse | string | undefined;
|
||||
data: PathResponse | undefined;
|
||||
update(path: string): Promise<void>;
|
||||
}
|
||||
|
||||
interface PathResponse {
|
||||
ok: number;
|
||||
path: string;
|
||||
total: number;
|
||||
is_dir: boolean;
|
||||
entries: Array<DirEntry>
|
||||
}
|
||||
|
||||
export interface DirEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
date: number;
|
||||
file_size: number;
|
||||
is_dir: boolean;
|
||||
}
|
||||
|
||||
export const usePath = create<PathState>((set) => ({
|
||||
|
@ -25,10 +30,6 @@ export const usePath = create<PathState>((set) => ({
|
|||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
set({ data: await res.json() });
|
||||
} catch {
|
||||
set({ data: `/api/path/${path}` });
|
||||
}
|
||||
set({ data: await res.json() });
|
||||
}
|
||||
}));
|
||||
|
|
22
src/store/raw.ts
Normal file
22
src/store/raw.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
interface RawState {
|
||||
data: string | undefined;
|
||||
update(path: string): Promise<void>;
|
||||
}
|
||||
|
||||
export const useRaw = create<RawState>((set) => ({
|
||||
data: undefined,
|
||||
update: async (path: string) => {
|
||||
const res = await fetch(`/api/raw/${path}`, {
|
||||
cache: "no-cache"
|
||||
});
|
||||
if (res.status !== 200 && res.status !== 304) {
|
||||
set({ data: undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
set({ data: text });
|
||||
}
|
||||
}));
|
18
src/util/unit.ts
Normal file
18
src/util/unit.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
export function convert(bytes: number): string {
|
||||
if (bytes >= 1125899906842624)
|
||||
return (bytes / 1125899906842624).toFixed(2) + " PiB";
|
||||
|
||||
if (bytes >= 1099511627776)
|
||||
return (bytes / 1099511627776).toFixed(2) + " TiB";
|
||||
|
||||
if (bytes >= 1073741824)
|
||||
return (bytes / 1073741824).toFixed(2) + " GiB";
|
||||
|
||||
if (bytes >= 1048576)
|
||||
return (bytes / 1048576).toFixed(2) + " MiB";
|
||||
|
||||
if (bytes >= 1024)
|
||||
return (bytes / 1024).toFixed(2) + " KiB";
|
||||
|
||||
return bytes + " Byte";
|
||||
}
|
Loading…
Reference in a new issue