Compare commits

..

6 commits

Author SHA1 Message Date
grngxd
f843155394 Add file preview component and enhance file size formatting in dashboard 2025-06-21 22:37:32 +01:00
grngxd
3217373024 q icon to osbar 2025-06-21 21:58:33 +01:00
grngxd
b36d3b76a1 Enhance dashboard components with new icons, layout adjustments, and improved file display 2025-06-21 20:27:32 +01:00
grngxd
f164351011 top bar 2025-06-21 17:44:55 +01:00
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
15 changed files with 347 additions and 352 deletions

View file

@ -1,9 +1,11 @@
# stereo.cat frontend # 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 clone https://git.iwakura.rip/stereo.cat/frontend.git
git submodule update --init --recursive git submodule update --init --recursive
bun install bun install
@ -11,4 +13,5 @@ bun dev
``` ```
## disclaimer ## 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) 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,45 @@
import { component$ } from "@builder.io/qwik";
import { SolarLibraryLinear, SolarQuestionCircleLinear, SolarRoundedMagniferLinear, SolarSettingsLinear, SolarUploadMinimalisticLinear, StereoCircularProgress, StereoLogoLinear } from "../misc/Icons";
export default component$(() => {
const used = 3.8;
const total = 15;
return (
<div class="absolute bottom-0 left-0 flex items-center justify-between p-7 px-16 w-full">
<div style={{
borderRadius: "999px",
border: "0.5px solid #FF264E",
background: "rgba(255, 38, 78, 0.15)",
boxShadow: "0px 4px 20px 0px rgba(255, 38, 78, 0.08), 0px 8px 12px 0px rgba(0, 0, 0, 0.12), 0px 4px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 1px 0px rgba(0, 0, 0, 0.04), 0px 4px 8px 0px rgba(255, 38, 78, 0.12) inset, 0px 1px 3px 0px rgba(255, 38, 78, 0.24) inset",
backdropFilter: "blur(12px)",
}} class="flex items-center justify-center px-6 py-4 gap-2 text-white text-xl">
<StereoCircularProgress value={used/total} class="text-2xl"/>
<p>{used} / {total} GB</p>
</div>
<div style={{
borderRadius: "999px",
border: "0.5px solid #FF264E",
background: "rgba(255, 38, 78, 0.15)",
boxShadow: "0px 4px 20px 0px rgba(255, 38, 78, 0.08), 0px 8px 12px 0px rgba(0, 0, 0, 0.12), 0px 4px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 1px 0px rgba(0, 0, 0, 0.04), 0px 4px 8px 0px rgba(255, 38, 78, 0.12) inset, 0px 1px 3px 0px rgba(255, 38, 78, 0.24) inset",
backdropFilter: "blur(12px)",
}} class="flex items-center justify-center px-6 py-4 gap-5 text-white text-3xl absolute left-1/2 transform -translate-x-1/2">
<StereoLogoLinear />
<SolarLibraryLinear />
<SolarUploadMinimalisticLinear />
<SolarRoundedMagniferLinear />
<SolarSettingsLinear />
</div>
<div style={{
borderRadius: "999px",
border: "0.5px solid #FF264E",
background: "rgba(255, 38, 78, 0.15)",
boxShadow: "0px 4px 20px 0px rgba(255, 38, 78, 0.08), 0px 8px 12px 0px rgba(0, 0, 0, 0.12), 0px 4px 4px 0px rgba(0, 0, 0, 0.06), 0px 2px 1px 0px rgba(0, 0, 0, 0.04), 0px 4px 8px 0px rgba(255, 38, 78, 0.12) inset, 0px 1px 3px 0px rgba(255, 38, 78, 0.24) inset",
backdropFilter: "blur(12px)",
}} class="flex items-center justify-center px-5 py-5 gap-2 text-white text-3xl h-full">
<SolarQuestionCircleLinear />
</div>
</div>
)
})

View file

@ -0,0 +1,45 @@
import { component$, useTask$ } from "@builder.io/qwik";
import { useNanostore$ } from "~/hooks/nanostores";
import { userInfo } from "~/lib/stores";
import { StereoUser } from "~/lib/types";
export default component$(() => {
const greetings = [
"what's on the agenda today, |?",
"what's on your mind, |?",
"what's the plan, |?",
"ready to rock, |?",
"what's brewing, |?",
"what's the latest, |?",
"how's your day going, |?",
"need some inspiration, |?",
"let's make some noise, |!",
"welcome back, |!",
"good to see you, |!",
"what are we making today, |?",
"time to make some magic, |!",
"let's get creative, |?",
"what's the vibe today, |?",
]
const greeting = greetings[Math.floor(Math.random() * greetings.length)];
const user = useNanostore$<StereoUser>(userInfo);
const splits = greeting.split("|");
useTask$(({ track }) => {
track(() => user.value);
})
return (
<div class="rounded-lg">
<p class="font-medium text-4xl text-stereo/45">
{splits[0]}
<span class="text-stereo">
@{user.value?.username || "..."}
</span>
{splits[1]}
</p>
</div>
)
})

View file

@ -1,5 +1,5 @@
import { component$ } from "@builder.io/qwik"; import { component$ } from "@builder.io/qwik";
import StereoLogo from "../misc/StereoLogo"; import { StereoLogoBold } from "../misc/Icons";
export default component$(() => { export default component$(() => {
return ( return (
@ -7,7 +7,7 @@ export default component$(() => {
<div class="flex flex-col flex-shrink h-full justify-start items-start gap-4"> <div class="flex flex-col flex-shrink h-full justify-start items-start gap-4">
<div class="flex flex-col"> <div class="flex flex-col">
<span class="flex gap-[1ch]"> <span class="flex gap-[1ch]">
<StereoLogo class="w-8 h-8 text-stereo" /> <StereoLogoBold class="w-8 h-8 text-stereo" />
<span class="text-white font-medium text-2xl">stereo<span class="text-stereo font-bold">.</span>cat</span> <span class="text-white font-medium text-2xl">stereo<span class="text-stereo font-bold">.</span>cat</span>
</span> </span>

View file

@ -1,5 +1,5 @@
import { component$ } from "@builder.io/qwik"; import { component$ } from "@builder.io/qwik";
import StereoLogo from "../misc/StereoLogo"; import { StereoLogoBold } from "../misc/Icons";
export default component$(() => { export default component$(() => {
const items = [ const items = [
@ -17,7 +17,7 @@ export default component$(() => {
data-aos-duration="1000" data-aos-duration="1000"
class="fixed flex items-center justify-start top-6 left-1/2 transform -translate-x-1/2 bg-neutral-950 p-8 h-10 rounded-full lg:w-2/3 md:w-4/5 w-4/5 z-[9999999] shadow-lg"> class="fixed flex items-center justify-start top-6 left-1/2 transform -translate-x-1/2 bg-neutral-950 p-8 h-10 rounded-full lg:w-2/3 md:w-4/5 w-4/5 z-[9999999] shadow-lg">
<div class="flex flex-1/3"> <div class="flex flex-1/3">
<StereoLogo class="w-10 h-10 text-stereo hover:text-white transition-all duration-300 hover:cursor-pointer" /> <StereoLogoBold class="w-10 h-10 text-stereo hover:text-white transition-all duration-300 hover:cursor-pointer" />
</div> </div>
<div class="w-full hidden md:flex flex-grow gap-4 items-center justify-center"> <div class="w-full hidden md:flex flex-grow gap-4 items-center justify-center">
{items.map(({ text, href, highlighted: h }) => ( {items.map(({ text, href, highlighted: h }) => (

View file

@ -28,7 +28,6 @@ export default component$(() => {
try { try {
const response = await ky.get(`https://api.lanyard.rest/v1/users/${id}`).json<LanyardResponse>(); const response = await ky.get(`https://api.lanyard.rest/v1/users/${id}`).json<LanyardResponse>();
lanyard.value = response; lanyard.value = response;
console.log("Lanyard data:", lanyard.value);
} catch (error) { } catch (error) {
console.error("Error fetching lanyard data:", error); console.error("Error fetching lanyard data:", error);
} }
@ -94,14 +93,16 @@ export default component$(() => {
/> />
<Testimonial <Testimonial
pfp="https://git.iwakura.rip/avatars/38bbf57a26f2891c59102582240386a4e2fa52b3999374673e0f4c4249ed4149?size=512"
nickname="hexlocation" nickname="hexlocation"
id="1325924978805440522" id="1325924978805440522"
quote="I've been using stereo for a while now, and I can't imagine going back to any other file host. It's just that good!" quote="I've been using stereo for a while now, and I can't imagine going back to any other file host. It's just that good!"
/> />
<Testimonial <Testimonial
nickname="an anonymous user" pfp="https://external-content.duckduckgo.com/iu/?u=https%3A%2F%2Fclipartcraft.com%2Fimages%2Ffire-emoji-transparent-snapchat-4.png&f=1&nofb=1&ipt=d59b80eec4d535f7f10b618a0daa9c0689a408643eaa6c1a054c0a03e7ca1835"
quote="stereo has changed the way I share files, it's so easy to use and the performance is top-notch. Highly recommend!" nickname="typed"
quote="stereo.cat saved my house from a fire, because when I was signing up I realized the email I had to log into needed my phone for 2fa, so when I went to the kitchen and grabbed my phone I noticed the stove was on, I thankfully turned it off. Thank you stereo.cat"
/> />
</div> </div>
<p class="text-xl text-white/50"> <p class="text-xl text-white/50">

View file

@ -1,5 +1,7 @@
import { QwikIntrinsicElements } from "@builder.io/qwik"; import { QwikIntrinsicElements } from "@builder.io/qwik";
// Solar - https://icones.js.org/collection/solar
export function SolarUploadLinear(props: QwikIntrinsicElements['svg'], key: string) { export function SolarUploadLinear(props: QwikIntrinsicElements['svg'], key: string) {
return ( return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props} key={key}><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.5"><path d="M17 9.002c2.175.012 3.353.109 4.121.877C22 10.758 22 12.172 22 15v1c0 2.829 0 4.243-.879 5.122C20.243 22 18.828 22 16 22H8c-2.828 0-4.243 0-5.121-.878C2 20.242 2 18.829 2 16v-1c0-2.828 0-4.242.879-5.121c.768-.768 1.946-.865 4.121-.877"></path><path stroke-linejoin="round" d="M12 15V2m0 0l3 3.5M12 2L9 5.5"></path></g></svg> <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props} key={key}><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.5"><path d="M17 9.002c2.175.012 3.353.109 4.121.877C22 10.758 22 12.172 22 15v1c0 2.829 0 4.243-.879 5.122C20.243 22 18.828 22 16 22H8c-2.828 0-4.243 0-5.121-.878C2 20.242 2 18.829 2 16v-1c0-2.828 0-4.242.879-5.121c.768-.768 1.946-.865 4.121-.877"></path><path stroke-linejoin="round" d="M12 15V2m0 0l3 3.5M12 2L9 5.5"></path></g></svg>
@ -26,7 +28,6 @@ export function SolarLinkRoundBold(props: QwikIntrinsicElements['svg'], key: str
) )
} }
export function SolarDownloadMinimalisticBold(props: QwikIntrinsicElements['svg'], key: string) { export function SolarDownloadMinimalisticBold(props: QwikIntrinsicElements['svg'], key: string) {
return ( return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props} key={key}><path fill="currentColor" d="M12.554 16.506a.75.75 0 0 1-1.107 0l-4-4.375a.75.75 0 0 1 1.107-1.012l2.696 2.95V3a.75.75 0 0 1 1.5 0v11.068l2.697-2.95a.75.75 0 1 1 1.107 1.013z" /><path fill="currentColor" d="M3.75 15a.75.75 0 0 0-1.5 0v.055c0 1.367 0 2.47.117 3.337c.12.9.38 1.658.981 2.26c.602.602 1.36.86 2.26.982c.867.116 1.97.116 3.337.116h6.11c1.367 0 2.47 0 3.337-.116c.9-.122 1.658-.38 2.26-.982s.86-1.36.982-2.26c.116-.867.116-1.97.116-3.337V15a.75.75 0 0 0-1.5 0c0 1.435-.002 2.436-.103 3.192c-.099.734-.28 1.122-.556 1.399c-.277.277-.665.457-1.4.556c-.755.101-1.756.103-3.191.103H9c-1.435 0-2.437-.002-3.192-.103c-.734-.099-1.122-.28-1.399-.556c-.277-.277-.457-.665-.556-1.4c-.101-.755-.103-1.756-.103-3.191" /></svg> <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props} key={key}><path fill="currentColor" d="M12.554 16.506a.75.75 0 0 1-1.107 0l-4-4.375a.75.75 0 0 1 1.107-1.012l2.696 2.95V3a.75.75 0 0 1 1.5 0v11.068l2.697-2.95a.75.75 0 1 1 1.107 1.013z" /><path fill="currentColor" d="M3.75 15a.75.75 0 0 0-1.5 0v.055c0 1.367 0 2.47.117 3.337c.12.9.38 1.658.981 2.26c.602.602 1.36.86 2.26.982c.867.116 1.97.116 3.337.116h6.11c1.367 0 2.47 0 3.337-.116c.9-.122 1.658-.38 2.26-.982s.86-1.36.982-2.26c.116-.867.116-1.97.116-3.337V15a.75.75 0 0 0-1.5 0c0 1.435-.002 2.436-.103 3.192c-.099.734-.28 1.122-.556 1.399c-.277.277-.665.457-1.4.556c-.755.101-1.756.103-3.191.103H9c-1.435 0-2.437-.002-3.192-.103c-.734-.099-1.122-.28-1.399-.556c-.277-.277-.457-.665-.556-1.4c-.101-.755-.103-1.756-.103-3.191" /></svg>
@ -37,3 +38,92 @@ export function SvgSpinnersBarsRotateFade(props: QwikIntrinsicElements['svg'], k
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props} key={key}><g><rect width="2" height="5" x="11" y="1" fill="currentColor" opacity=".14" /><rect width="2" height="5" x="11" y="1" fill="currentColor" opacity=".29" transform="rotate(30 12 12)" /><rect width="2" height="5" x="11" y="1" fill="currentColor" opacity=".43" transform="rotate(60 12 12)" /><rect width="2" height="5" x="11" y="1" fill="currentColor" opacity=".57" transform="rotate(90 12 12)" /><rect width="2" height="5" x="11" y="1" fill="currentColor" opacity=".71" transform="rotate(120 12 12)" /><rect width="2" height="5" x="11" y="1" fill="currentColor" opacity=".86" transform="rotate(150 12 12)" /><rect width="2" height="5" x="11" y="1" fill="currentColor" transform="rotate(180 12 12)" /><animateTransform attributeName="transform" calcMode="discrete" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;30 12 12;60 12 12;90 12 12;120 12 12;150 12 12;180 12 12;210 12 12;240 12 12;270 12 12;300 12 12;330 12 12;360 12 12" /></g></svg> <svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props} key={key}><g><rect width="2" height="5" x="11" y="1" fill="currentColor" opacity=".14" /><rect width="2" height="5" x="11" y="1" fill="currentColor" opacity=".29" transform="rotate(30 12 12)" /><rect width="2" height="5" x="11" y="1" fill="currentColor" opacity=".43" transform="rotate(60 12 12)" /><rect width="2" height="5" x="11" y="1" fill="currentColor" opacity=".57" transform="rotate(90 12 12)" /><rect width="2" height="5" x="11" y="1" fill="currentColor" opacity=".71" transform="rotate(120 12 12)" /><rect width="2" height="5" x="11" y="1" fill="currentColor" opacity=".86" transform="rotate(150 12 12)" /><rect width="2" height="5" x="11" y="1" fill="currentColor" transform="rotate(180 12 12)" /><animateTransform attributeName="transform" calcMode="discrete" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;30 12 12;60 12 12;90 12 12;120 12 12;150 12 12;180 12 12;210 12 12;240 12 12;270 12 12;300 12 12;330 12 12;360 12 12" /></g></svg>
) )
} }
export function SolarLibraryLinear(props: QwikIntrinsicElements['svg'], key: string) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props} key={key}><g fill="none" stroke="currentColor" stroke-width="1.5"><path d="M19.562 7a2.132 2.132 0 0 0-2.1-2.5H6.538a2.132 2.132 0 0 0-2.1 2.5M17.5 4.5c.028-.26.043-.389.043-.496a2 2 0 0 0-1.787-1.993C15.65 2 15.52 2 15.26 2H8.74c-.26 0-.391 0-.497.011a2 2 0 0 0-1.787 1.993c0 .107.014.237.043.496" /><path stroke-linecap="round" d="M15 18H9" /><path d="M2.384 13.793c-.447-3.164-.67-4.745.278-5.77C3.61 7 5.298 7 8.672 7h6.656c3.374 0 5.062 0 6.01 1.024s.724 2.605.278 5.769l-.422 3c-.35 2.48-.525 3.721-1.422 4.464s-2.22.743-4.867.743h-5.81c-2.646 0-3.97 0-4.867-.743s-1.072-1.983-1.422-4.464z" /></g></svg>
)
}
export function SolarUploadMinimalisticLinear(props: QwikIntrinsicElements['svg'], key: string) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props} key={key}><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 15c0 2.828 0 4.243.879 5.121C4.757 21 6.172 21 9 21h6c2.828 0 4.243 0 5.121-.879C21 19.243 21 17.828 21 15m-9 1V3m0 0l4 4.375M12 3L8 7.375" /></svg>
)
}
export function SolarRoundedMagniferLinear(props: QwikIntrinsicElements['svg'], key: string) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props} key={key}><g fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="9" /><path stroke-linecap="round" d="M21.812 20.975c-.063.095-.176.208-.403.434c-.226.227-.34.34-.434.403a1.13 1.13 0 0 1-1.62-.408c-.053-.1-.099-.254-.19-.561c-.101-.335-.151-.503-.161-.621a1.13 1.13 0 0 1 1.218-1.218c.118.01.285.06.621.16c.307.092.46.138.56.192a1.13 1.13 0 0 1 .409 1.619Z" /></g></svg>
)
}
export function SolarSettingsLinear(props: QwikIntrinsicElements['svg'], key: string) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props} key={key}><g fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="3" /><path d="M13.765 2.152C13.398 2 12.932 2 12 2s-1.398 0-1.765.152a2 2 0 0 0-1.083 1.083c-.092.223-.129.484-.143.863a1.62 1.62 0 0 1-.79 1.353a1.62 1.62 0 0 1-1.567.008c-.336-.178-.579-.276-.82-.308a2 2 0 0 0-1.478.396C4.04 5.79 3.806 6.193 3.34 7s-.7 1.21-.751 1.605a2 2 0 0 0 .396 1.479c.148.192.355.353.676.555c.473.297.777.803.777 1.361s-.304 1.064-.777 1.36c-.321.203-.529.364-.676.556a2 2 0 0 0-.396 1.479c.052.394.285.798.75 1.605c.467.807.7 1.21 1.015 1.453a2 2 0 0 0 1.479.396c.24-.032.483-.13.819-.308a1.62 1.62 0 0 1 1.567.008c.483.28.77.795.79 1.353c.014.38.05.64.143.863a2 2 0 0 0 1.083 1.083C10.602 22 11.068 22 12 22s1.398 0 1.765-.152a2 2 0 0 0 1.083-1.083c.092-.223.129-.483.143-.863c.02-.558.307-1.074.79-1.353a1.62 1.62 0 0 1 1.567-.008c.336.178.579.276.819.308a2 2 0 0 0 1.479-.396c.315-.242.548-.646 1.014-1.453s.7-1.21.751-1.605a2 2 0 0 0-.396-1.479c-.148-.192-.355-.353-.676-.555A1.62 1.62 0 0 1 19.562 12c0-.558.304-1.064.777-1.36c.321-.203.529-.364.676-.556a2 2 0 0 0 .396-1.479c-.052-.394-.285-.798-.75-1.605c-.467-.807-.7-1.21-1.015-1.453a2 2 0 0 0-1.479-.396c-.24.032-.483.13-.82.308a1.62 1.62 0 0 1-1.566-.008a1.62 1.62 0 0 1-.79-1.353c-.014-.38-.05-.64-.143-.863a2 2 0 0 0-1.083-1.083Z" /></g></svg>
)
}
export function SolarQuestionCircleLinear(props: QwikIntrinsicElements['svg'], key: string) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" {...props} key={key}><g fill="none"><circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="1.5" /><path stroke="currentColor" stroke-linecap="round" stroke-width="1.5" d="M10.125 8.875a1.875 1.875 0 1 1 2.828 1.615c-.475.281-.953.708-.953 1.26V13" /><circle cx="12" cy="16" r="1" fill="currentColor" /></g></svg>
)
}
// Stereo
export function StereoLogoBold(props: QwikIntrinsicElements['svg'], key: string) {
return (
<svg width="1em" height="1em" viewBox="0 0 281 248" fill="none" xmlns="http://www.w3.org/2000/svg" {...props} key={key}><path d="M0.744835 19.1645C0.340705 16.0258 0.876443 12.8377 2.2843 10.0034L6.37392 1.7703C7.01957 0.470487 8.7912 0.269553 9.71155 1.39175L85.375 93.6495H195.875L271.538 1.39175C272.459 0.269551 274.23 0.470487 274.876 1.77029L278.966 10.0034C280.374 12.8377 280.909 16.0258 280.505 19.1645L264.378 144.419C256.8 203.277 206.688 247.35 147.344 247.35H133.906C74.5619 247.35 24.4504 203.277 16.872 144.419L0.744835 19.1645Z" fill="currentColor"/></svg>
)
}
export function StereoLogoLinear(props: QwikIntrinsicElements['svg'], key: string) {
return (
<svg width="1em" height="1em" viewBox="0 0 21 18" fill="none" xmlns="http://www.w3.org/2000/svg" {...props} key={key}><path d="M1.26893 2.381C1.21059 1.92943 1.28782 1.4706 1.49067 1.06358C1.58237 0.879581 1.8316 0.851009 1.96205 1.00954L6.83479 6.93126H14.1652L19.038 1.00954C19.1684 0.851008 19.4176 0.879581 19.5093 1.06358C19.7122 1.4706 19.7894 1.92943 19.7311 2.381L18.7758 9.77571C18.235 13.9617 14.693 17.0937 10.5 17.0937C6.30697 17.0937 2.76496 13.9617 2.2242 9.77571L1.26893 2.381Z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>
)
}
export function StereoCircularProgress(
{ value, ...svgProps }: QwikIntrinsicElements['svg'] & { value: number },
key: string
) {
const radius = 10;
const circumference = 2 * Math.PI * radius;
const dashOffset = circumference * (1 - value);
return (
<svg
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...svgProps}
key={key}
>
<circle
cx="12"
cy="12"
r={radius}
fill="none"
stroke="currentColor"
stroke-opacity={0.25}
stroke-width={2}
/>
<circle
cx="12"
cy="12"
r={radius}
fill="none"
stroke="currentColor"
stroke-width={2}
stroke-dasharray={`${circumference} ${circumference}`}
stroke-dashoffset={dashOffset}
transform="rotate(-90 12 12)"
/>
</svg>
);
}

View file

@ -1,9 +0,0 @@
import { component$, QwikIntrinsicElements } from "@builder.io/qwik";
export default component$((props: QwikIntrinsicElements['svg']) => {
return (
<svg width="281" height="248" viewBox="0 0 281 248" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path d="M0.744835 19.1645C0.340705 16.0258 0.876443 12.8377 2.2843 10.0034L6.37392 1.7703C7.01957 0.470487 8.7912 0.269553 9.71155 1.39175L85.375 93.6495H195.875L271.538 1.39175C272.459 0.269551 274.23 0.470487 274.876 1.77029L278.966 10.0034C280.374 12.8377 280.909 16.0258 280.505 19.1645L264.378 144.419C256.8 203.277 206.688 247.35 147.344 247.35H133.906C74.5619 247.35 24.4504 203.277 16.872 144.419L0.744835 19.1645Z" fill="currentColor"/>
</svg>
)
})

View file

@ -15,4 +15,5 @@ export const api = {
return await client.post('upload', { body: formData }); return await client.post('upload', { body: formData });
}, },
delete: async (uid: string) => await client.delete(uid).json(), 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,12 @@
import { atom } from "nanostores"; import { atom } from "nanostores";
import { StereoFile } from "./types"; import { StereoFile, StereoUser } from "./types";
export const areFilesLoaded = atom<boolean>(false); export const areFilesLoaded = atom<boolean>(false);
export const dashboardFiles = atom<StereoFile[]>([]); export const dashboardFiles = atom<StereoFile[]>([]);
export const userInfo = atom<StereoUser>({
id: "1",
username: "user",
blacklisted: false,
email: "user@example.com",
created_at: Date.now().toString(),
});

View file

@ -6,3 +6,11 @@ export type StereoFile = {
CreatedAt: string; CreatedAt: string;
Mime: 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, useSignal, useTask$, useVisibleTask$ } from "@builder.io/qwik";
import { routeLoader$, type DocumentHead } from "@builder.io/qwik-city"; 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 Dropzone from "~/components/Dropzone";
import File from "~/components/dashboard/File";
import { SolarUploadLinear, SvgSpinnersBarsRotateFade } from "~/components/misc/Icons";
import { useNanostore$ } from "~/hooks/nanostores"; import { useNanostore$ } from "~/hooks/nanostores";
import { api } from "~/lib/api"; import { api } from "~/lib/api";
import { OAUTH_LINK } from "~/lib/constants";
import { areFilesLoaded, dashboardFiles } from "~/lib/stores"; import { areFilesLoaded, dashboardFiles } from "~/lib/stores";
import { StereoFile } from "~/lib/types"; import { StereoFile } from "~/lib/types";
export const useAuthCheck = routeLoader$(({ cookie, redirect: r }) => { export const useAuthCheck = routeLoader$(({ cookie, redirect: r }) => {
const jwt = cookie.get("jwt"); const jwt = cookie.get("jwt");
if (jwt) return {}; if (jwt) return {};
throw r(302, "/"); throw r(302, OAUTH_LINK);
}); });
export default component$(() => { export default component$(() => {
@ -22,47 +22,127 @@ export default component$(() => {
useVisibleTask$(async () => { useVisibleTask$(async () => {
loaded.value = false; loaded.value = false;
files.value = await api.list(); files.value = await api.list();
console.log("Files loaded:", files.value);
loaded.value = true; loaded.value = true;
}); });
return ( return (
<> <div class="flex flex-col w-full h-screen p-8 gap-6 bg-gradient-to-b from-stereo/20 to-transparent justify-self-end">
{/* <Dropzone /> */} <TitleBar />
<Controlbar /> <Files files={files} loaded={loaded} />
{!loaded.value ? ( <OSBar />
<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> </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">{ 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`;
"(╯°Д°)╯︵ /(.□ . \\)", }
"┬─┬ ︵ /(.□. \\",
"(/ .□.)\\ ︵╰(゜Д゜)╯︵ /(.□. \\)" const Files = component$<{
].sort(() => Math.random() - 0.5)[0] files: Signal<StereoFile[]>;
}</p> loaded: Signal<boolean>;
<p class="text-gray-700 text-2xl font-light italic">you haven't uploaded any files yet!</p> // eslint-disable-next-line @typescript-eslint/no-unused-vars
<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> }>(({ files, loaded }) => {
</div>
) const File = component$(({ file }: { file: StereoFile }) => {
: ( const Preview = component$(() => {
<div class="grid xl:grid-cols-4 md:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-3 p-6 mb-14"> type FileType =
{files.value.map((file) => ( | "image"
<File key={file.Name} file={file} /> | "video"
))} | "audio"
</div> | "other";
)
const fileType: Signal<FileType> = useSignal<FileType>("other");
const type = file.Mime.split("/")[1];
useTask$(async () => {
if (
["jpeg", "jpg", "png", "gif", "webp"]
.includes(type)
) fileType.value = "image";
else if (
["mp4", "webm", "ogg", "avi", "mov"]
.includes(type)
) fileType.value = "video";
else if (
["mp3", "wav", "flac", "aac"]
.includes(type)
) fileType.value = "audio";
else fileType.value = "other";
});
return (
<div class="w-full h-60 object-cover flex-grow relative">
{fileType.value === "image" && (
<img
width={400}
height={300}
src={`/api/${file.ID}`}
alt={file.Name}
class="w-full h-60 object-cover flex-grow"
/>
)} )}
</> {fileType.value === "video" && (
<video
width={400}
height={300}
src={`/api/${file.ID}`}
controls
class="w-full h-60 object-cover flex-grow"
/>
)}
{fileType.value === "audio" && (
<audio
src={`/api/${file.ID}`}
controls
class="w-full h-60 object-cover flex-grow"
/>
)}
{fileType.value === "other" && (
<div class="w-full h-60 flex items-center justify-center bg-gray-200 text-gray-500">
<p>Unsupported file type</p>
</div>
)}
</div>
)
})
return (
<div style={{
background: "linear-gradient(180deg, rgba(255, 38, 78, 0.00) 57.21%, rgba(255, 38, 78, 0.17) 100%); ",
boxShadow: "0px 4px 21.2px 2px rgba(255, 38, 78, 0.05)",
}} class="transition-all rounded-3xl flex flex-col overflow-clip items-center justify-center">
<Preview />
<div class="flex flex-col items-center justify-center text-center w-full h-full p-5">
<p class="text-xl">{file.Name}</p>
<p class="text-stereo/50 text-lg">
{formatSize(file.Size)}
<span class="text-stereo/40"> </span>
Uploaded on {new Date(file.CreatedAt).toLocaleDateString()}
</p>
</div>
</div>
)
})
return (
<div class="mb-6 flex flex-col w-full max-h-full overflow-y-auto overflow-x-hidden mask-clip-content rounded-3xl">
<div class="w-full h-full grid grid-cols-4 gap-2">
{files.value.map((file) => (
<File key={file.ID} file={file} />
))}
</div>
</div>
); );
}); });

View file

@ -1,9 +1,20 @@
import { $, component$, Slot, useOnDocument } from '@builder.io/qwik'; import { $, component$, Slot, useOnDocument, useVisibleTask$ } from '@builder.io/qwik';
import AOS from 'aos'; import AOS from 'aos';
import 'aos/dist/aos.css'; 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$(() => { export default component$(() => {
useOnDocument("DOMContentLoaded", $(() => { const info = useNanostore$<StereoUser>(userInfo);
useVisibleTask$(async () => {
info.value = await api.me();
})
useOnDocument("DOMContentLoaded", $(async () => {
AOS.init({ AOS.init({
once: true, once: true,
duration: 1000, duration: 1000,