Compare commits
2 commits
59a33754e2
...
f47a1b2226
Author | SHA1 | Date | |
---|---|---|---|
![]() |
f47a1b2226 | ||
![]() |
0d29d59937 |
10 changed files with 66 additions and 333 deletions
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
})
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
});
|
9
src/components/dashboard/OSBar.tsx
Normal file
9
src/components/dashboard/OSBar.tsx
Normal 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>
|
||||
)
|
||||
})
|
9
src/components/dashboard/TitleBar.tsx
Normal file
9
src/components/dashboard/TitleBar.tsx
Normal 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>
|
||||
)
|
||||
})
|
|
@ -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,
|
||||
}
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
|
@ -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: [
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue