Complete Code Push

This commit is contained in:
Brandon 2025-05-18 19:45:30 -04:00
parent ff5983217c
commit b594083bb1
19 changed files with 528 additions and 11 deletions

31
.gitignore vendored
View File

@ -1,11 +1,24 @@
# ---> Vue
# gitignore template for Vue.js projects
#
# Recommended template: Node.gitignore
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# TODO: where does this rule come from?
docs/_book
# TODO: where does this rule come from?
test/
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -1,3 +1,5 @@
# unloze-wrapper
# Vue 3 + TypeScript + Vite
Unloze Fast Download Wrapper
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Unloze Fast Download</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "unloze-fast-download-wrapper",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.812.0",
"@mdi/font": "^7.4.47",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"path": "^0.12.7",
"url": "^0.11.4",
"vue": "^3.5.13",
"vuetify": "^3.8.0-beta.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"@vue/tsconfig": "^0.7.0",
"sass": "^1.87.0",
"sass-loader": "^16.0.5",
"typescript": "~5.8.3",
"vite": "^6.3.5",
"vue-tsc": "^2.2.8"
}
}

BIN
public/UNLOZE_logo_blue.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
public/carbon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

79
server.js Normal file
View File

@ -0,0 +1,79 @@
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
import cors from "cors";
import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";
// Load environment variables
dotenv.config();
// Set up __dirname in ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const port = process.env.PORT || 3000;
app.use(cors());
const s3 = new S3Client({
region: "auto",
endpoint: process.env.R2_ENDPOINT,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY,
secretAccessKey: process.env.R2_SECRET_KEY,
},
});
app.get("/api/list", async (req, res) => {
const prefix = req.query.prefix || "";
const token = req.query.token;
const search = req.query.search?.toLowerCase();
try {
const response = await s3.send(
new ListObjectsV2Command({
Bucket: process.env.R2_BUCKET,
Prefix: prefix,
ContinuationToken: token,
Delimiter: "/",
})
);
const filteredFolders = (response.CommonPrefixes || []).filter(
(p) => !search || p.Prefix.toLowerCase().includes(search)
);
const filteredFiles = (response.Contents || [])
.filter((obj) => obj.Size > 0)
.filter((obj) => !search || obj.Key.toLowerCase().includes(search));
res.json({
folders: filteredFolders.map((p) => p.Prefix),
files: filteredFiles.map((obj) => ({
key: obj.Key,
size: obj.Size,
lastModified: obj.LastModified,
})),
nextToken: response.NextContinuationToken || null,
isTruncated: response.IsTruncated || false,
});
} catch (err) {
console.error(err);
res.status(500).json({ error: "Failed to list objects" });
}
});
// === STATIC FRONTEND ===
app.use(express.static(path.join(__dirname, 'dist')));
// === Fallback to index.html for client-side routing ===
app.get('/{*splat}', (req, res) => {
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
});
// === START SERVER ===
app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);
});

11
src/App.vue Normal file
View File

@ -0,0 +1,11 @@
<template>
<v-app>
<v-main>
<download-list />
</v-main>
</v-app>
</template>
<script setup lang="ts">
import DownloadList from './components/DownloadList.vue'
</script>

View File

@ -0,0 +1,227 @@
<template>
<v-container class="text-center pt-6">
<v-img
src="/UNLOZE_logo_blue.png"
max-width="200"
alt="UNLOZE Logo"
class="mx-auto"
contain
/>
</v-container>
<v-container>
<v-card>
<v-breadcrumbs :items="breadcrumbItems" class="my-4">
<template v-slot:item="{ item }">
<span v-if="item.disabled" class="grey--text text--darken-2">
{{ item.title }}
</span>
<span
v-else
class="blue--text text--darken-2"
style="cursor: pointer"
@click="goToBreadcrumb((item as any).value)"
>
{{ item.title }}
</span>
</template>
</v-breadcrumbs>
<v-text-field
v-model="search"
label="Search files..."
prepend-inner-icon="mdi-magnify"
hide-details
density="compact"
class="ma-4"
@keyup.enter="onSearch"
/>
<v-progress-linear v-if="loading" indeterminate color="blue" />
<v-data-table
:headers="headers"
:items="combinedItems"
:items-per-page="-1"
class="elevation-1"
disable-pagination
hide-default-footer
>
<template #item.name="{ item }">
<span
v-if="item.isDir"
class="blue--text text--darken-2"
@click="enterFolder(item.path)"
style="cursor: pointer"
>
<v-icon small class="me-2">mdi-folder-outline</v-icon>
{{ item.name }}
</span>
<span v-else>
<v-icon small class="me-2">mdi-file-outline</v-icon>
<a
:href="`${DOWNLOAD_BASE_URL}${currentPath}${item.name}`"
target="_blank"
class="blue--text text--darken-2"
style="text-decoration: none"
>
{{ item.name }}
</a>
</span>
</template>
<template #item.size="{ item }">
{{ item.isDir ? "-" : formatSize(item.size) }}
</template>
<template #item.lastModified="{ item }">
{{
item.lastModified
? new Date(item.lastModified).toLocaleString()
: "-"
}}
</template>
</v-data-table>
</v-card>
</v-container>
</template>
<script lang="ts">
import { defineComponent, ref, nextTick } from "vue";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
interface FolderItem {
name: string;
path: string;
isDir: true;
size: null;
lastModified: null;
}
interface FileItem {
name: string;
path: string;
isDir: false;
size: number;
lastModified: string;
}
interface BreadcrumbItem {
title: string;
value: string;
disabled: boolean;
}
export default defineComponent({
data() {
return {
currentPath: "",
folders: [] as string[],
files: [] as {
key: string;
size: number;
lastModified: string;
}[],
nextToken: null as string | null,
loading: false,
search: "",
headers: [
{ title: "Name", key: "name" },
{ title: "Size", key: "size", width: "120px" },
{ title: "Last Modified", key: "lastModified", width: "200px" },
],
DOWNLOAD_BASE_URL: import.meta.env.VITE_DOWNLOAD_BASE_URL
};
},
computed: {
combinedItems(): (FolderItem | FileItem)[] {
const folderItems: FolderItem[] = this.folders.map((p) => ({
name: this.extractFolderName(p),
path: p,
isDir: true,
size: null,
lastModified: null,
}));
const fileItems: FileItem[] = this.files.map((f) => ({
name: f.key.split("/").pop() || f.key,
path: f.key,
isDir: false,
size: f.size,
lastModified: f.lastModified,
}));
return [...folderItems, ...fileItems];
},
breadcrumbItems(): BreadcrumbItem[] {
const parts = this.currentPath.split("/").filter((p) => p !== "");
const items: BreadcrumbItem[] = [
{ title: "Home", value: "", disabled: parts.length === 0 },
];
let cumulativePath = "";
parts.forEach((part, index) => {
cumulativePath += part + "/";
items.push({
title: part,
value: cumulativePath,
disabled: index === parts.length - 1,
});
});
return items;
},
},
methods: {
async fetchList(reset = false) {
if (this.loading) return;
this.loading = true;
const url = new URL(`${API_BASE_URL}/api/list`);
url.searchParams.set("prefix", this.currentPath);
url.searchParams.set("limit", "50");
if (this.search) url.searchParams.set("search", this.search);
if (this.nextToken && !reset) {
url.searchParams.set("token", this.nextToken);
}
const res = await fetch(url.toString());
const data = await res.json();
if (reset) {
this.folders = [];
this.files = [];
nextTick(() => {
const container = this.$refs.scrollContainer as HTMLElement;
if (container) container.scrollTop = 0;
});
}
this.folders.push(...(data.folders || []));
this.files.push(...(data.files || []));
this.nextToken = data.nextToken;
this.loading = false;
},
onSearch() {
this.nextToken = null;
this.fetchList(true);
},
enterFolder(path: string) {
this.currentPath = path;
this.nextToken = null;
this.fetchList(true);
},
goToBreadcrumb(path: string) {
this.currentPath = path;
this.nextToken = null;
this.fetchList(true);
},
extractFolderName(path: string): string {
const parts = path.split("/");
return parts[parts.length - 2] || path;
},
formatSize(size: number): string {
if (size > 1e6) return (size / 1e6).toFixed(1) + " MB";
if (size > 1e3) return (size / 1e3).toFixed(1) + " KB";
return size + " B";
},
},
mounted() {
this.fetchList(true);
},
});
</script>

20
src/main.css Normal file
View File

@ -0,0 +1,20 @@
/* src/main.css */
@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap');
body {
margin: 0;
padding: 0;
font-family: 'Roboto', sans-serif;
overflow: hidden;
}
#app,
.v-application {
background-image: url('/carbon.jpg');
background-repeat: no-repeat;
background-position: center;
position: relative;
z-index: 0;
height: 100vh;
overflow-y: auto;
}

9
src/main.ts Normal file
View File

@ -0,0 +1,9 @@
import { createApp } from 'vue'
import App from './App.vue'
import { vuetify } from './plugins/vuetify'
import '@mdi/font/css/materialdesignicons.css'
import './main.css';
const app = createApp(App)
app.use(vuetify)
app.mount('#app')

34
src/plugins/vuetify.ts Normal file
View File

@ -0,0 +1,34 @@
// src/plugins/vuetify.ts
import 'vuetify/styles'
import { createVuetify } from 'vuetify'
import { aliases, mdi } from 'vuetify/iconsets/mdi'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
export const vuetify = createVuetify({
components,
directives,
icons: {
defaultSet: 'mdi',
aliases,
sets: { mdi },
},
theme: {
defaultTheme: 'dark',
themes: {
dark: {
dark: true,
colors: {
background: '#121212',
surface: '#1E1E1E',
primary: '#BB86FC',
secondary: '#03DAC6',
error: '#CF6679',
info: '#2196F3',
success: '#4CAF50',
warning: '#FB8C00',
},
},
},
},
})

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

15
tsconfig.app.json Normal file
View File

@ -0,0 +1,15 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

20
tsconfig.json Normal file
View File

@ -0,0 +1,20 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"types": ["vuetify"],
"module": "ESNext",
"moduleResolution": "Node",
"target": "ESNext",
"jsx": "preserve",
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}

25
tsconfig.node.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

1
tsconfig.tsbuildinfo Normal file
View File

@ -0,0 +1 @@
{"root":["./src/app.vue","./src/main.ts","./src/vite-env.d.ts","./src/components/downloadlist.vue","./src/plugins/vuetify.ts"],"version":"5.8.3"}

16
vite.config.ts Normal file
View File

@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/fastdl': {
target: 'https://uk-fastdl.unloze.com',
changeOrigin: true,
rewrite: path => path.replace(/^\/fastdl/, '')
}
}
}
})