Compare commits

...

2 commits

Author SHA1 Message Date
grngxd
f47a1b2226 templating dashboard & add user route to api client 2025-06-21 17:02:52 +01:00
grngxd
0d29d59937 change oauth link 2025-06-21 16:15:33 +01:00
10 changed files with 66 additions and 333 deletions

View file

@ -1,9 +1,11 @@
# stereo.cat frontend
written in typescript with qwik
written in typescript with qwik & bun
## running in dev env
```
## development
https://bun.sh/docs/installation
```bash
git clone https://git.iwakura.rip/stereo.cat/frontend.git
git submodule update --init --recursive
bun install
@ -11,4 +13,5 @@ bun dev
```
## disclaimer
All graphic assets belonging to stereo.cat may not be used in unofficial instances, forks or versions of our software. Please replace them if you are hosting our software yourself, they can be found in the ``public`` folder in this repository. More information (like the full license) can be found [here](https://git.iwakura.rip/stereo.cat/public)

View file

@ -1,88 +0,0 @@
import { $, component$, noSerialize, NoSerialize, useSignal, useVisibleTask$ } from "@builder.io/qwik";
import { useNanostore$ } from "~/hooks/nanostores";
import { api } from "~/lib/api";
import { areFilesLoaded, dashboardFiles } from "~/lib/stores";
import { StereoFile } from "~/lib/types";
import { SolarUploadLinear, SvgSpinnersBarsRotateFade } from "../misc/Icons";
import StereoLogo from "../misc/StereoLogo";
export default component$(() => {
const loaded = useNanostore$<boolean>(areFilesLoaded);
const files = useNanostore$<StereoFile[]>(dashboardFiles);
const fileInputRef = useSignal<HTMLInputElement>();
const uploadingFiles = useSignal<NoSerialize<File[]> | undefined>();
const now = useSignal(new Date());
useVisibleTask$(() => {
const interval = setInterval(() => {
now.value = new Date();
}, 500);
return () => clearInterval(interval);
});
const uploadFiles = $(async () => {
if (!uploadingFiles.value) {
console.error("No file(s) selected for upload.");
return;
}
try {
const ufiles = uploadingFiles.value as File[];
for (const file of ufiles) {
const name = file.name.replace(/[^a-zA-Z0-9_.-]/g, "_");
const f = new File([file], name, { type: file.type });
await api.upload(f);
}
files.value = await api.list();
} catch (error) {
console.error("Error uploading file:", error);
}
})
return (
<div class="z-[999999999] fixed bottom-4 left-1/2 transform -translate-x-1/2 bg-neutral-700/10 backdrop-blur-3xl lg:w-1/3 md:w-2/3 w-4/5 p-2 pr-4 rounded-lg flex items-center justify-between">
<input
type="file"
ref={fileInputRef}
style="display: none;"
onChange$={async (e: Event) => {
uploadingFiles.value = noSerialize(Object.values((e.target as HTMLInputElement).files || {}));
await uploadFiles();
}}
multiple
/>
<div class="flex items-center gap-2">
{/* TODO: replace this button with a modal with options like settings log out etc */}
<button
class="duration-100 hover:bg-white text-white hover:text-black p-2 rounded-lg"
onClick$={async () => {
loaded.value = false;
files.value = await api.list()
loaded.value = true;
}}
>
{
loaded.value ? (
<StereoLogo class="w-6 h-6" />
) : (
<SvgSpinnersBarsRotateFade class="w-6 h-6" />
)
}
</button>
<p class="text-white/25 font-light text-xl"> | </p>
<button
class="duration-100 hover:bg-white text-white hover:text-black p-2 rounded-lg"
onClick$={() => { fileInputRef.value?.click() }}
>
<SolarUploadLinear class="w-6 h-6" />
</button>
</div>
<p class="text-white font-medium">{now.value.toLocaleTimeString()}</p>
</div>
)
})

View file

@ -1,199 +0,0 @@
import { $, component$, Signal, useSignal, useTask$ } from "@builder.io/qwik";
import { useNanostore$ } from "~/hooks/nanostores";
import { api } from "~/lib/api";
import { dashboardFiles } from "~/lib/stores";
import { StereoFile } from "~/lib/types";
import { SolarClipboardAddBold, SolarDownloadMinimalisticBold, SolarTrashBin2Bold } from "../misc/Icons";
type FileProps = {
file: StereoFile;
}
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
export default component$(({ file }: FileProps) => {
const files = useNanostore$<StereoFile[]>(dashboardFiles);
const deleteFile = $(async (id: string) => {
if (!confirm("Are you sure you want to delete this file?")) return;
await api.delete(id);
files.value = await api.list();
});
const addFileToClipboard = $(async () => {
const response = await api.file(file.Name);
const data = await response.blob();
let mime = data.type || "application/octet-stream";
let clip;
if (navigator.clipboard && window.ClipboardItem) {
if (mime === "image/jpeg" || mime === "image/jpg") {
const img = document.createElement("img");
img.src = URL.createObjectURL(data);
await new Promise((res) => (img.onload = res));
const canvas = document.createElement("canvas");
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext("2d");
ctx?.drawImage(img, 0, 0);
const png = await new Promise<Blob>((resolve) =>
canvas.toBlob((b) => resolve(b!), "image/png")
);
mime = "image/png";
clip = new ClipboardItem({ [mime]: png });
} else {
clip = new ClipboardItem({ [mime]: data });
}
try {
await navigator.clipboard.write([clip]);
alert("File added to clipboard successfully!");
} catch (error) {
console.error("Failed to add file to clipboard:", error);
alert("Failed to add file to clipboard. Please try again.");
}
} else {
alert("Clipboard API not supported in this browser.");
}
});
return (
<div class="rounded-xl bg-neutral-900 flex flex-col group overflow-hidden hover:bg-neutral-800 transition-all duration-200">
<div class="relative">
<a
href={`/api/${file.ID}`}
target="_blank"
class="flex w-full h-60 overflow-clip"
>
<div class="flex flex-grow group-hover:scale-105 transition-all duration-500 bg-neutral-800">
<FilePreview file={file} />
</div>
</a>
<div class="absolute bottom-2 right-2 gap-2 z-10 group-hover:flex hidden duration-200 transition-all">
<a
class="bg-neutral-600/40 backdrop-blur-lg hover:bg-neutral-600/75 transition-all duration-200 text-white p-2 rounded-lg active:scale-95"
href={`/api/${file.ID}`}
target="_blank"
>
<SolarDownloadMinimalisticBold class="w-6 h-6"/>
</a>
<button
class="bg-green-600/50 backdrop-blur-lg hover:bg-green-600/75 transition-all duration-200 text-white p-2 rounded-lg active:scale-95"
onClick$={async () => await addFileToClipboard()}
>
<SolarClipboardAddBold class="w-6 h-6"/>
</button>
<button
class="bg-red-600/50 backdrop-blur-lg hover:bg-red-600/75 transition-all duration-200 text-white p-2 rounded-lg active:scale-95"
onClick$={async () => await deleteFile(file.ID)}
>
<SolarTrashBin2Bold class="w-6 h-6"/>
</button>
</div>
</div>
<div class="flex justify-center items-center h-full">
<div class="p-4 flex flex-col w-full text-center">
<p class="text-lg font-semibold text-white w-full truncate">
{ file.Name || "Untitled" }
</p>
<div class="flex gap-1 text-sm text-neutral-500 items-center justify-center">
<span>{ formatSize(file.Size) }</span>
<span class="text-neutral-600"></span>
<span>Uploaded on { new Date(file.CreatedAt).toLocaleDateString() }</span>
</div>
</div>
</div>
</div>
)
})
const FilePreview = component$(({ file }: FileProps) => {
type FileType =
| "image"
| "video"
| "audio"
| "other";
const type: Signal<FileType> = useSignal<FileType>("other");
const extension = file.Name.split('.').pop()?.toLowerCase() || "";
useTask$(async () => {
if (
["png", "jpg", "jpeg", "gif"]
.includes(extension)) type.value = "image";
else if (
["mp4", "webm", "ogg", "avi", "mov", "mkv"]
.includes(extension)) type.value = "video";
else if (
["mp3", "wav", "ogg", "flac", "aac"]
.includes(extension)) type.value = "audio";
else type.value = "other";
});
switch (type.value) {
case "image":
return (
<div class="w-full h-60 object-cover flex-grow relative">
<img
width={400}
height={300}
src={`/api/${file.ID}`}
alt={file.Name}
class="w-full h-60 object-cover flex-grow"
/>
</div>
);
case "video":
return (
<div class="w-full h-60 object-cover flex-grow relative">
<video
width={400}
height={300}
src={`/api/${file.ID}`}
class="w-full h-60 object-cover flex-grow"
controls
autoplay
muted
>
<div class="w-full h-60 flex items-center justify-center">
<p class="text-white/50 text-lg font-light">
Preview not available
</p>
</div>
</video>
</div>
);
case "audio":
return (
<div class="w-full h-60 flex items-center justify-center p-2">
<audio
controls
class="w-full h-12"
src={`/api/${file.ID}`}
>
Your browser does not support the audio element.
</audio>
</div>
);
case "other":
default:
return (
<div class="w-full h-60 flex items-center justify-center">
<p class="text-white/50 text-lg font-light">
Preview not available
</p>
</div>
);
}
});

View file

@ -0,0 +1,9 @@
import { component$ } from "@builder.io/qwik";
export default component$(() => {
return (
<div class="flex w-full h-48 bg-red-500 align-bottom">
a
</div>
)
})

View file

@ -0,0 +1,9 @@
import { component$ } from "@builder.io/qwik";
export default component$(() => {
return (
<div class="bg-blue-500">
<p>whats on the agenda today, @hexlocation?</p>
</div>
)
})

View file

@ -15,4 +15,5 @@ export const api = {
return await client.post('upload', { body: formData });
},
delete: async (uid: string) => await client.delete(uid).json(),
me: async () => (await client.get('auth/me').json() as any).user,
}

View file

@ -1,5 +1,6 @@
import { atom } from "nanostores";
import { StereoFile } from "./types";
import { StereoFile, StereoUser } from "./types";
export const areFilesLoaded = atom<boolean>(false);
export const dashboardFiles = atom<StereoFile[]>([]);
export const dashboardFiles = atom<StereoFile[]>([]);
export const userInfo = atom<StereoUser>({} as StereoUser);

View file

@ -5,4 +5,12 @@ export type StereoFile = {
Size: number;
CreatedAt: string;
Mime: string;
}
export type StereoUser = {
id: string;
username: string;
blacklisted: boolean;
email: string;
created_at: string;
}

View file

@ -1,18 +1,18 @@
import { component$, useVisibleTask$ } from "@builder.io/qwik";
import { component$, Signal, useVisibleTask$ } from "@builder.io/qwik";
import { routeLoader$, type DocumentHead } from "@builder.io/qwik-city";
import Controlbar from "~/components/dashboard/Controlbar";
import OSBar from "~/components/dashboard/OSBar";
import TitleBar from "~/components/dashboard/TitleBar";
// import Dropzone from "~/components/Dropzone";
import File from "~/components/dashboard/File";
import { SolarUploadLinear, SvgSpinnersBarsRotateFade } from "~/components/misc/Icons";
import { useNanostore$ } from "~/hooks/nanostores";
import { api } from "~/lib/api";
import { OAUTH_LINK } from "~/lib/constants";
import { areFilesLoaded, dashboardFiles } from "~/lib/stores";
import { StereoFile } from "~/lib/types";
export const useAuthCheck = routeLoader$(({ cookie, redirect: r }) => {
const jwt = cookie.get("jwt");
if (jwt) return {};
throw r(302, "/");
throw r(302, OAUTH_LINK);
});
export default component$(() => {
@ -22,50 +22,30 @@ export default component$(() => {
useVisibleTask$(async () => {
loaded.value = false;
files.value = await api.list();
console.log("Files loaded:", files.value);
loaded.value = true;
});
return (
<>
{/* <Dropzone /> */}
<Controlbar />
{!loaded.value ? (
<div class="absolute w-full h-screen flex justify-center items-center flex-col">
<p class="text-gray-500 text-8xl font-bold"><SvgSpinnersBarsRotateFade /></p>
<p class="text-gray-700 text-2xl font-light italic">loading your files...</p>
<span class="text-gray-700 text-lg font-light flex gap-[0.5ch] items-center">please wait... <span class="animate-spin"></span></span>
</div>
) : (
files.value.length === 0 ? (
<div class="absolute w-full h-screen flex justify-center items-center flex-col">
<p class="text-gray-500 text-8xl font-bold">{
[
"┻━┻︵ \\(°□°)/ ︵ ┻━┻",
"┻━┻︵ヽ(`Д´)ノ︵ ┻━┻",
"ʕノ•ᴥ•ʔノ ︵ ┻━┻",
"(╯°Д°)╯︵ /(.□ . \\)",
"┬─┬ ︵ /(.□. \\",
"(/ .□.)\\ ︵╰(゜Д゜)╯︵ /(.□. \\)"
].sort(() => Math.random() - 0.5)[0]
}</p>
<p class="text-gray-700 text-2xl font-light italic">you haven't uploaded any files yet!</p>
<span class="text-gray-700 text-lg font-light flex gap-[0.5ch] items-center">click the <span class="animate-bounce p-0.5 bg-gray-500 rounded-sm"><SolarUploadLinear /></span> button to get started</span>
</div>
)
: (
<div class="grid xl:grid-cols-4 md:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-3 p-6 mb-14">
{files.value.map((file) => (
<File key={file.Name} file={file} />
))}
</div>
)
)}
<TitleBar />
<Files files={files} loaded={loaded} />
<OSBar />
</>
);
});
const Files = component$<{
files: Signal<StereoFile[]>;
loaded: Signal<boolean>;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
}>(({ files, loaded }) => {
return (
<div class="flex flex-col flex-grow w-full h-full bg-green-500">
b
</div>
);
});
export const head: DocumentHead = {
title: "Welcome to Qwik",
meta: [

View file

@ -1,9 +1,18 @@
import { $, component$, Slot, useOnDocument } from '@builder.io/qwik';
import AOS from 'aos';
import 'aos/dist/aos.css';
import { useNanostore$ } from '~/hooks/nanostores';
import { api } from '~/lib/api';
import { userInfo } from '~/lib/stores';
import { StereoUser } from '~/lib/types';
export default component$(() => {
useOnDocument("DOMContentLoaded", $(() => {
const info = useNanostore$<StereoUser>(userInfo);
useOnDocument("DOMContentLoaded", $(async () => {
info.value = await api.me()
console.log(info.value);
AOS.init({
once: true,
duration: 1000,