feat: file view

This commit is contained in:
Project_IO 2025-03-16 02:00:54 +09:00
parent 28e3a75593
commit 34880f4abc
12 changed files with 394 additions and 25 deletions

View file

@ -3,6 +3,7 @@ package routes
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath"
"git.wh64.net/devproje/kuma-archive/internal/service" "git.wh64.net/devproje/kuma-archive/internal/service"
"github.com/gin-contrib/static" "github.com/gin-contrib/static"
@ -14,22 +15,28 @@ func New(app *gin.Engine, apiOnly bool) {
{ {
api.GET("/path/*path", func(ctx *gin.Context) { api.GET("/path/*path", func(ctx *gin.Context) {
worker := service.NewWorkerService() worker := service.NewWorkerService()
path := ctx.Param("path") path := ctx.Param("path")
data, err := worker.Read(path) data, err := worker.Read(path)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err) _, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
ctx.Status(404) ctx.Status(404)
return return
} }
if !data.IsDir { 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 return
} }
raw, err := os.ReadDir(data.Path) raw, err := os.ReadDir(data.Path)
if err != nil { if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "%v\n", err)
ctx.Status(500) ctx.Status(500)
return return
} }
@ -44,7 +51,8 @@ func New(app *gin.Engine, apiOnly bool) {
entries = append(entries, service.DirEntry{ entries = append(entries, service.DirEntry{
Name: entry.Name(), Name: entry.Name(),
Path: path, Path: filepath.Join(path, entry.Name()),
Date: finfo.ModTime().Unix(),
FileSize: uint64(finfo.Size()), FileSize: uint64(finfo.Size()),
IsDir: finfo.IsDir(), IsDir: finfo.IsDir(),
}) })
@ -53,9 +61,47 @@ func New(app *gin.Engine, apiOnly bool) {
ctx.JSON(200, gin.H{ ctx.JSON(200, gin.H{
"ok": 1, "ok": 1,
"path": path, "path": path,
"total": data.FileSize,
"is_dir": true,
"entries": entries, "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 { if apiOnly {

View file

@ -12,6 +12,7 @@ type WorkerService struct{}
type DirEntry struct { type DirEntry struct {
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` Path string `json:"path"`
Date int64 `json:"date"`
FileSize uint64 `json:"file_size"` FileSize uint64 `json:"file_size"`
IsDir bool `json:"is_dir"` IsDir bool `json:"is_dir"`
} }
@ -29,8 +30,10 @@ func (sv *WorkerService) Read(path string) (*DirEntry, error) {
ret := DirEntry{ ret := DirEntry{
Name: info.Name(), Name: info.Name(),
Date: info.ModTime().Unix(),
Path: fullpath, Path: fullpath,
FileSize: uint64(info.Size()), FileSize: uint64(info.Size()),
IsDir: info.IsDir(),
} }
return &ret, nil return &ret, nil
} }

View file

@ -43,17 +43,40 @@
.ka-menu-item { .ka-menu-item {
width: 100%; width: 100%;
height: 55px; height: 45px;
padding: 5px 0.5rem;
display: flex; display: flex;
padding: 0 0.5rem;
align-items: center; align-items: center;
span { span {
margin: 0 5px; margin: 0 5px;
} }
&:hover {
background-color: var(--nav-hover);
}
&:focus {
color: var(--focus);
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
width: 100%; 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;
}
}

View file

@ -1,10 +1,12 @@
import { BrowserRouter, Route, Routes, useLocation } from "react-router";
import { usePath } from "./store/path";
import { useEffect, useState } from "react"; 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 "./App.scss";
import kuma from "./assets/kuma.png"; import kuma from "./assets/kuma.png";
import { DynamicIcon, IconName } from "lucide-react/dynamic"; import FileView from "./components/file-view";
function App() { function App() {
return ( return (
@ -25,6 +27,8 @@ function Dashboard() {
if (!load) { if (!load) {
path.update(location.pathname.substring(1, location.pathname.length)); path.update(location.pathname.substring(1, location.pathname.length));
setLoad(true); setLoad(true);
return;
} }
const id = setInterval(() => { const id = setInterval(() => {
@ -34,9 +38,25 @@ function Dashboard() {
return () => clearInterval(id); return () => clearInterval(id);
}, [load, path, location]); }, [load, path, location]);
if (!load) {
return <></>;
}
return ( return (
<main className="container-md ka-view"> <main className="container-md ka-view">
<Header /> <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> </main>
); );
} }
@ -46,7 +66,7 @@ function Header() {
return ( return (
<nav className="ka-nav"> <nav className="ka-nav">
<a className="title"> <a className="title" href="/">
<img src={kuma} alt="" /> <img src={kuma} alt="" />
<h4 className="title-content">Kuma Archive</h4> <h4 className="title-content">Kuma Archive</h4>
</a> </a>
@ -77,16 +97,47 @@ function MenuItem({ icon, name, block }: { icon: IconName, name: string, block?:
return ( return (
<a className={"ka-menu-item link"} onClick={ev => { <a className={"ka-menu-item link"} onClick={ev => {
ev.preventDefault(); ev.preventDefault();
if (typeof block === "undefined") if (typeof block === "undefined")
return; return;
block(); block();
}}> }}>
<DynamicIcon name={icon} className="link" /> <DynamicIcon name={icon} />
<span>{name}</span> <span>{name}</span>
</a> </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">
&copy; 2020-2025 Project_IO. MIT License. Powered by WSERVER.
</div>
</footer>
);
}
export default App; export default App;

View file

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

View file

@ -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() { function Directory() {
const path = usePath();
if (typeof path.data === "undefined")
return <></>;
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>
); );
} }

View file

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

View file

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

View file

@ -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://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 { :root {
--background: #242424; --background: #242424;
@ -31,6 +32,7 @@
--btn-danger-focus: #a72532; --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-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 { html, body {
@ -184,3 +186,7 @@ input, button {
border-radius: 25px; border-radius: 25px;
padding: 0.25rem 15px; padding: 0.25rem 15px;
} }
code, pre {
font-family: var(--font-mono);
}

View file

@ -1,19 +1,24 @@
import { create } from "zustand"; import { create } from "zustand";
interface PathState { interface PathState {
data: PathResponse | string | undefined; data: PathResponse | undefined;
update(path: string): Promise<void>; update(path: string): Promise<void>;
} }
interface PathResponse { interface PathResponse {
ok: number; ok: number;
path: string; path: string;
total: number;
is_dir: boolean;
entries: Array<DirEntry> entries: Array<DirEntry>
} }
export interface DirEntry { export interface DirEntry {
name: string; name: string;
path: string;
date: number;
file_size: number; file_size: number;
is_dir: boolean;
} }
export const usePath = create<PathState>((set) => ({ export const usePath = create<PathState>((set) => ({
@ -25,10 +30,6 @@ export const usePath = create<PathState>((set) => ({
return; return;
} }
try {
set({ data: await res.json() }); set({ data: await res.json() });
} catch {
set({ data: `/api/path/${path}` });
}
} }
})); }));

22
src/store/raw.ts Normal file
View 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
View 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";
}