Compare commits

..

No commits in common. "f47a1b22262161de5f72a64c38983d3564add7b2" and "59a33754e2c686b018d798ec68bf1ad8e7e8db5b" have entirely different histories.

10 changed files with 333 additions and 66 deletions

View file

@ -1,11 +1,9 @@
# stereo.cat frontend
written in typescript with qwik & bun
written in typescript with qwik
## development
https://bun.sh/docs/installation
```bash
## running in dev env
```
git clone https://git.iwakura.rip/stereo.cat/frontend.git
git submodule update --init --recursive
bun install
@ -13,5 +11,4 @@ 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

@ -0,0 +1,88 @@
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

@ -0,0 +1,199 @@
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

@ -1,9 +0,0 @@
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

@ -1,9 +0,0 @@
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,5 +15,4 @@ 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,6 +1,5 @@
import { atom } from "nanostores";
import { StereoFile, StereoUser } from "./types";
import { StereoFile } from "./types";
export const areFilesLoaded = atom<boolean>(false);
export const dashboardFiles = atom<StereoFile[]>([]);
export const userInfo = atom<StereoUser>({} as StereoUser);

View file

@ -6,11 +6,3 @@ export type StereoFile = {
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$, Signal, useVisibleTask$ } from "@builder.io/qwik";
import { component$, useVisibleTask$ } from "@builder.io/qwik";
import { routeLoader$, type DocumentHead } from "@builder.io/qwik-city";
import OSBar from "~/components/dashboard/OSBar";
import TitleBar from "~/components/dashboard/TitleBar";
import Controlbar from "~/components/dashboard/Controlbar";
// 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, OAUTH_LINK);
throw r(302, "/");
});
export default component$(() => {
@ -22,27 +22,47 @@ export default component$(() => {
useVisibleTask$(async () => {
loaded.value = false;
files.value = await api.list();
console.log("Files loaded:", files.value);
loaded.value = true;
});
return (
<>
<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
{/* <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>
)
)}
</>
);
});

View file

@ -1,18 +1,9 @@
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$(() => {
const info = useNanostore$<StereoUser>(userInfo);
useOnDocument("DOMContentLoaded", $(async () => {
info.value = await api.me()
console.log(info.value);
useOnDocument("DOMContentLoaded", $(() => {
AOS.init({
once: true,
duration: 1000,