Compare commits

...
Sign in to create a new pull request.

17 commits

Author SHA1 Message Date
hex
59a33754e2 more disclaimers 2025-06-15 23:21:13 +02:00
hex
0806ae54c2 real 2025-06-15 23:17:10 +02:00
hex
d2b2f11baf readme 2025-06-15 23:15:29 +02:00
hex
253c3f6e4c add separate repo 2025-06-15 23:12:03 +02:00
hex
34a4c6b2ce remove public 2025-06-15 23:11:48 +02:00
grngxd
2975e6088d fix delete route 2025-06-15 10:13:30 +01:00
grngxd
7926efa222 fix backend 2025-06-14 20:28:59 +01:00
grngxd
9f91514f78 gitignore 2025-06-14 17:18:43 +01:00
grngxd
d36c98cb49 make the landing page look beautiful 😍😍😍 2025-06-14 17:05:49 +01:00
grngxd
a72ebe853f enhance component 2025-06-10 16:26:05 +01:00
grngxd
68b13ab5c2 fix: responsiveness 2025-06-10 14:49:39 +01:00
grngxd
cf9f2181e9 style: adjust layout spacing in dashboard and file component 2025-06-10 13:41:43 +01:00
grngxd
1aedda9cb3 fix: add more table flips 2025-06-10 01:31:21 +01:00
grngxd
7be62fcbb2 half broken dropzone component to be used later fr 2025-06-10 01:19:27 +01:00
grngxd
39005e75e0 refactor: update file upload handling to support multiple files 2025-06-10 00:46:04 +01:00
grngxd
c45d8f1e64 ??? 2025-06-10 00:32:56 +01:00
grngxd
1d48ab8256 refactor to dashboard! 2025-06-10 00:31:23 +01:00
31 changed files with 6846 additions and 117 deletions

1
.gitignore vendored
View file

@ -8,6 +8,7 @@
node_modules node_modules
.env .env
*.local *.local
vite.config.ts.timestamp-*
# Cache # Cache
.cache .cache

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "public"]
path = public
url = ssh://git@git.iwakura.rip:6969/stereo.cat/public.git

View file

@ -1,3 +1,14 @@
# stereo.cat frontend # stereo.cat frontend
written in typescript with qwik written in typescript with qwik
## running in dev env
```
git clone https://git.iwakura.rip/stereo.cat/frontend.git
git submodule update --init --recursive
bun install
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

@ -4,6 +4,8 @@
"": { "": {
"name": "my-qwik-empty-starter", "name": "my-qwik-empty-starter",
"dependencies": { "dependencies": {
"@types/aos": "^3.0.7",
"aos": "^3.0.0-beta.6",
"ky": "^1.8.1", "ky": "^1.8.1",
"nanostores": "^1.0.1", "nanostores": "^1.0.1",
"tailwind-scrollbar": "^4.0.2", "tailwind-scrollbar": "^4.0.2",
@ -245,6 +247,8 @@
"@trysound/sax": ["@trysound/sax@0.2.0", "", {}, "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA=="], "@trysound/sax": ["@trysound/sax@0.2.0", "", {}, "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA=="],
"@types/aos": ["@types/aos@3.0.7", "", {}, "sha512-sEhyFqvKauUJZDbvAB3Pggynrq6g+2PS4XB3tmUr+mDL1gfDJnwslUC4QQ7/l8UD+LWpr3RxZVR/rHoZrLqZVg=="],
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@ -297,6 +301,8 @@
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"aos": ["aos@3.0.0-beta.6", "", { "dependencies": { "classlist-polyfill": "^1.2.0", "lodash.debounce": "^4.0.8", "lodash.throttle": "^4.1.1" } }, "sha512-VLWrpq8bfAWcetynVHMMrqdC+89Qq/Ym6UBJbHB4crIwp3RR8uq1dNGgsFzoDl03S43rlVMK+na3r5+oUCZsYw=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="], "array-buffer-byte-length": ["array-buffer-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "is-array-buffer": "^3.0.5" } }, "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw=="],
@ -345,6 +351,8 @@
"chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="],
"classlist-polyfill": ["classlist-polyfill@1.2.0", "", {}, "sha512-GzIjNdcEtH4ieA2S8NmrSxv7DfEV5fmixQeyTmqmRmRJPGpRBaSnA2a0VrCjyT8iW8JjEdMbKzDotAJf+ajgaQ=="],
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="],
@ -655,8 +663,12 @@
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.debounce": ["lodash.debounce@4.0.8", "", {}, "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"lodash.throttle": ["lodash.throttle@4.1.1", "", {}, "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], "magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="],

6069
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -42,6 +42,8 @@
"vite-tsconfig-paths": "^4.2.1" "vite-tsconfig-paths": "^4.2.1"
}, },
"dependencies": { "dependencies": {
"@types/aos": "^3.0.7",
"aos": "^3.0.0-beta.6",
"ky": "^1.8.1", "ky": "^1.8.1",
"nanostores": "^1.0.1", "nanostores": "^1.0.1",
"tailwind-scrollbar": "^4.0.2" "tailwind-scrollbar": "^4.0.2"

1
public Submodule

@ -0,0 +1 @@
Subproject commit a4f5725fbaf2db1053be198d81d08e1ea0c3e843

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 500 500"><g clip-path="url(#a)"><circle cx="250" cy="250" r="250" fill="#fff"/><path fill="#18B6F6" d="m367.87 418.45-61.17-61.18-.94.13v-.67L175.7 227.53l32.05-31.13L188.9 87.73 99.56 199.09c-15.22 15.42-18.03 40.51-7.08 59.03l55.83 93.11a46.82 46.82 0 0 0 40.73 22.81l27.65-.27 151.18 44.68Z"/><path fill="#AC7EF4" d="m401.25 196.94-12.29-22.81-6.41-11.67-2.54-4.56-.26.26-33.66-58.63a47.07 47.07 0 0 0-41.27-23.75l-29.51.8-88.01.28a47.07 47.07 0 0 0-40.33 23.34L93.4 207l95.76-119.54L314.7 226.19l-22.3 22.67 13.35 108.54.13-.26v.26h-.26l.26.27 10.42 10.2 50.62 49.78c2.13 2 5.6-.4 4.13-2.96l-31.25-61.85 54.5-101.3 1.73-2c.67-.81 1.33-1.62 1.87-2.42a46.8 46.8 0 0 0 3.34-50.18Z"/><path fill="#fff" d="M315.1 225.65 189.18 87.6l17.9 108.14L175 227l130.5 130.27-11.75-108.14 21.37-23.48Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h500v500H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 947 B

View file

@ -1,3 +0,0 @@
<svg width="281" height="248" viewBox="0 0 281 248" fill="none" xmlns="http://www.w3.org/2000/svg">
<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="#D9D9D9"/>
</svg>

Before

Width:  |  Height:  |  Size: 548 B

View file

@ -1,9 +0,0 @@
{
"$schema": "https://json.schemastore.org/web-manifest-combined.json",
"name": "qwik-project-name",
"short_name": "Welcome to Qwik",
"start_url": ".",
"display": "standalone",
"background_color": "#fff",
"description": "A Qwik project app."
}

View file

View file

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

View file

@ -0,0 +1,62 @@
import { component$, useVisibleTask$ } from "@builder.io/qwik";
import { useDropzone } from "~/hooks/dropzone";
import { useNanostore$ } from "~/hooks/nanostores";
import { api } from "~/lib/api";
import { dashboardFiles } from "~/lib/stores";
import { StereoFile } from "~/lib/types";
export default component$(() => {
const dashfiles = useNanostore$<StereoFile[]>(dashboardFiles);
const {
highlight,
onDragOver,
onDragLeave,
onInputChange,
triggerFileInput,
fileInputRef,
} = useDropzone();
useVisibleTask$(() => {
const dropzone = document.getElementById("dropzone");
if (!dropzone) return;
const handler = async (e: DragEvent) => {
e.preventDefault();
highlight.value = false;
const files = Array.from(e.dataTransfer?.files || []);
for (const file of files) {
const name = file.name.replace(/[^a-zA-Z0-9_.-]/g, "_");
const f = new File([file], name, { type: file.type });
await api.upload(f);
dashfiles.value = await api.list();
}
};
dropzone.addEventListener("drop", handler);
return () => dropzone.removeEventListener("drop", handler);
});
return (
<div
id="dropzone"
preventdefault:drop
class={{
"relative z-10 border-2 border-dashed rounded-lg p-10 transition-colors": true,
"border-white bg-black/20": highlight.value,
"border-neutral-800": !highlight.value,
}}
onDragOver$={onDragOver}
onDragLeave$={onDragLeave}
onClick$={triggerFileInput}
>
<p class="text-center text-neutral-500 pointer-events-none">drop file ehre</p>
<input
type="file"
ref={fileInputRef}
multiple
accept="image/*"
class="hidden"
onChange$={onInputChange}
/>
</div>
);
});

View file

@ -1,9 +1,9 @@
import { $, component$ } from "@builder.io/qwik"; import { $, component$, Signal, useSignal, useTask$ } from "@builder.io/qwik";
import { useNanostore$ } from "~/hooks/nanostores"; import { useNanostore$ } from "~/hooks/nanostores";
import { api } from "~/lib/api"; import { api } from "~/lib/api";
import { dashboardFiles } from "~/lib/stores"; import { dashboardFiles } from "~/lib/stores";
import { StereoFile } from "~/lib/types"; import { StereoFile } from "~/lib/types";
import { SolarClipboardAddBold, SolarDownloadMinimalisticBold, SolarTrashBin2Bold } from "./Icons"; import { SolarClipboardAddBold, SolarDownloadMinimalisticBold, SolarTrashBin2Bold } from "../misc/Icons";
type FileProps = { type FileProps = {
file: StereoFile; file: StereoFile;
@ -19,9 +19,9 @@ const formatSize = (bytes: number) => {
export default component$(({ file }: FileProps) => { export default component$(({ file }: FileProps) => {
const files = useNanostore$<StereoFile[]>(dashboardFiles); const files = useNanostore$<StereoFile[]>(dashboardFiles);
const deleteFile = $(async (uid: string, name: string) => { const deleteFile = $(async (id: string) => {
if (!confirm("Are you sure you want to delete this file?")) return; if (!confirm("Are you sure you want to delete this file?")) return;
console.log(await api.delete(uid, name)); await api.delete(id);
files.value = await api.list(); files.value = await api.list();
}); });
@ -65,22 +65,20 @@ export default component$(({ file }: FileProps) => {
return ( return (
<div class="rounded-xl bg-neutral-900 flex flex-col group overflow-hidden hover:bg-neutral-800 transition-all duration-200"> <div class="rounded-xl bg-neutral-900 flex flex-col group overflow-hidden hover:bg-neutral-800 transition-all duration-200">
<div class="relative"> <div class="relative">
<a href={`/api/${file.Owner}/${file.Name}`} target="_blank"> <a
{ (file.Name.endsWith(".png") || file.Name.endsWith(".jpg") || file.Name.endsWith(".jpeg")) && ( href={`/api/${file.ID}`}
<img target="_blank"
width={400} class="flex w-full h-60 overflow-clip"
height={300} >
src={`/api/${file.Owner}/${file.Name}`} <div class="flex flex-grow group-hover:scale-105 transition-all duration-500 bg-neutral-800">
alt={file.Name} <FilePreview file={file} />
class="w-full h-60 object-cover bg-neutral-800 flex-grow" </div>
/>
)}
</a> </a>
<div class="absolute bottom-2 right-2 gap-2 z-10 group-hover:flex hidden duration-200 transition-all"> <div class="absolute bottom-2 right-2 gap-2 z-10 group-hover:flex hidden duration-200 transition-all">
<a <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" 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.Owner}/${file.Name}`} href={`/api/${file.ID}`}
target="_blank" target="_blank"
> >
<SolarDownloadMinimalisticBold class="w-6 h-6"/> <SolarDownloadMinimalisticBold class="w-6 h-6"/>
@ -95,7 +93,7 @@ export default component$(({ file }: FileProps) => {
<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" 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.Owner, file.Name)} onClick$={async () => await deleteFile(file.ID)}
> >
<SolarTrashBin2Bold class="w-6 h-6"/> <SolarTrashBin2Bold class="w-6 h-6"/>
</button> </button>
@ -117,3 +115,85 @@ export default component$(({ file }: FileProps) => {
</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,23 @@
import { component$ } from "@builder.io/qwik";
import { OAUTH_LINK } from "~/lib/constants";
export default component$(() => (
<div
data-aos="fade-up"
data-aos-duration="1000"
class="flex flex-col gap-1.5 items-center justify-center w-4/5 bg-gradient-to-b from-stereo/30 to-transparent rounded-2xl h-96 py-4"
>
<p class="text-4xl text-center">
ready to try the <span class="text-stereo">stereo</span> experience?
</p>
<p class="text-xl text-white/80 text-center">
join over <span class="text-stereo">100k</span> other people hosting their files with <span class="text-stereo">stereo</span>!
</p>
<a
href={OAUTH_LINK}
class="px-12 py-1.5 mt-1.5 text-lg font-medium text-white bg-stereo rounded-full hover:text-stereo hover:bg-white transition duration-300"
>
get started
</a>
</div>
));

View file

@ -0,0 +1,29 @@
import { component$ } from "@builder.io/qwik";
import StereoLogo from "../misc/StereoLogo";
export default component$(() => {
return (
<div class="w-screen flex py-16 px-16">
<div class="flex flex-col flex-shrink h-full justify-start items-start gap-4">
<div class="flex flex-col">
<span class="flex gap-[1ch]">
<StereoLogo 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>
<span class="text-white/80 font-light text-lg">
store all your precious moments with <span class="text-stereo font-medium">stereo</span>.
</span>
<span class="text-white/80 font-light text-md">
copyright © {new Date().getFullYear()} stereo.cat - all rights reserved.
</span>
</div>
</div>
<div class="flex flex-grow justify-end items-start gap-12">
</div>
</div>
)
})

View file

@ -0,0 +1,83 @@
import { component$, useSignal } from "@builder.io/qwik";
import { useRelativeMouse } from "~/hooks/mouse";
import { OAUTH_LINK } from "~/lib/constants";
import GradientBorder from "../misc/GradientBorder";
export default component$(() => {
const ref1 = useSignal<HTMLElement>();
const mouse1 = useRelativeMouse(ref1);
const ref2 = useSignal<HTMLElement>();
const mouse2 = useRelativeMouse(ref2);
return (
<div class="flex flex-col w-full bg-gradient-to-b from-stereo/30 to-transparent overflow-x-clip select-none">
<div class="mt-62 flex flex-col justify-center w-full h-full text-center">
<div class="flex flex-col items-center justify-center gap-2 font-light">
<p class="text-6xl">
you bring the files, we'll bring the <span class="text-stereo">magic</span>.
</p>
<p class="text-2xl text-white/80">
stereo is no-bs file-host inspired by Tixte, that you'll love to use
</p>
<div class="flex gap-4 mt-1">
<a
href={OAUTH_LINK}
class="px-6 py-1 text-lg font-medium text-white bg-stereo rounded-full hover:text-stereo hover:bg-white transition duration-300"
>
get started
</a>
<a
href="discord.gg"
target="_blank"
rel="noopener noreferrer"
class="px-6 py-1 text-lg font-medium text-stereo rounded-full border border-stereo hover:bg-stereo hover:text-white transition duration-300"
>
learn more
</a>
</div>
<div class="mt-10 flex items-center" style={{ perspective: "1000px" }}>
<GradientBorder
ref={ref1}
size="3px"
from="#ff264e"
to="transparent"
direction="to bottom"
style={{
transformStyle: "preserve-3d",
transformOrigin: "center center",
transform: `
rotateX(${(mouse1.y / 120) + 30}deg)
rotateY(${(-mouse1.x / 120) + 25}deg)
rotateZ(-10deg)
`,
}}
class="rounded-2xl -mr-12"
>
<img src="dashboard 1.png" class="h-[32rem] rounded-2xl shadow-2xl shadow-stereo/10" />
</GradientBorder>
<GradientBorder
ref={ref2}
size="3px"
from="#ff264e"
to="transparent"
direction="to bottom"
style={{
transformStyle: "preserve-3d",
transformOrigin: "center center",
transform: `
rotateX(${(mouse2.y / 120) + 30}deg)
rotateY(${(-mouse2.x / 60) - 25}deg)
rotateZ(10deg)
`,
}}
class="rounded-2xl -ml-12 shadow-2xl shadow-stereo/10"
>
<img src="dashboard 2.png" class="h-[36rem] rounded-2xl" />
</GradientBorder>
</div>
</div>
</div>
</div>
);
});

View file

@ -0,0 +1,41 @@
import { component$ } from "@builder.io/qwik";
import StereoLogo from "../misc/StereoLogo";
export default component$(() => {
const items = [
{ text: "Synopsis", href: "#" },
{ text: "Discord", href: "#" },
{ text: "Dashboard", href: "/dashboard", highlighted: true },
{ text: "Pricing", href: "#" },
{ text: "Terms", href: "#" }
];
return (
<div
data-aos="fade-down"
data-aos-anchor-placement="top-center"
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">
<div class="flex flex-1/3">
<StereoLogo class="w-10 h-10 text-stereo hover:text-white transition-all duration-300 hover:cursor-pointer" />
</div>
<div class="w-full hidden md:flex flex-grow gap-4 items-center justify-center">
{items.map(({ text, href, highlighted: h }) => (
<a
key={text}
href={href}
class={
h ? "px-4 py-0.5 hover:text-stereo hover:bg-white rounded-full font-medium bg-stereo text-white transition duration-300 text-lg"
: "text-white font-light hover:text-stereo transition duration-300 text-lg"
}
>
{text}
</a>
))}
</div>
<div class="flex flex-1/3 items-center justify-end">
<span class="text-white group hover:text-stereo transition duration-300 font-medium text-2xl">stereo<span class="text-stereo group-hover:text-white transition duration-300 font-bold">.</span>cat</span>
</div>
</div>
)
});

View file

@ -0,0 +1,40 @@
import { component$ } from "@builder.io/qwik";
export default component$(() => {
type StatProps = {
stat: string,
description: string;
}
const Stat = component$(({ stat, description }: StatProps) => (
<div class="group hover:scale-105 transition-all duration-300 bg-gradient-to-t from-stereo/30 to-transparent p-8 px-12 rounded-2xl h-96 flex flex-col items-center text-center shadow-2xl shadow-stereo/15">
<div class="flex-grow flex items-center justify-center">
<p class="text-7xl font-bold text-stereo group-hover:text-white transition-colours duration-300">
{stat.includes("+") ? stat.substring(0, stat.length - 1) : stat}
{stat.endsWith("+") ? <span class="text-stereo/80 text-4xl align-super">+</span> : ""}
</p>
</div>
<p class="text-xl w-3/4 text-wrap text-white/30 group-hover:text-white/60 transition-colours duration-300">{description}</p>
</div>
) );
return (
<div
data-aos="fade-up"
data-aos-duration="1000"
class="flex flex-col gap-12 items-center justify-center w-full"
>
<div class="flex flex-col gap-1 items-center justify-center">
<p class="text-lg text-stereo font-bold uppercase">statistics</p>
<p class="text-2xl text-white/80">
we know what you're thinking, and we have the numbers to prove it.
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<Stat stat="1.5M+" description="files hosted on stereo" />
<Stat stat="100k+" description="active users on stereo" />
<Stat stat="99.9%" description="uptime guarantee for all files" />
</div>
</div>
)
});

View file

@ -0,0 +1,112 @@
import { $, component$, useOnDocument, useSignal } from "@builder.io/qwik";
import ky from "ky";
export default component$(() => {
type TestimonialProps = {
nickname: string;
pfp?: string;
id?: string;
quote: string;
};
type LanyardResponse = {
data: {
discord_status: "online" | "idle" | "dnd";
discord_user: {
avatar: string;
}
},
success: boolean;
}
const Testimonial = component$(({ nickname, id, quote, pfp }: TestimonialProps) => {
const lanyard = useSignal<LanyardResponse>();
useOnDocument("DOMContentLoaded", $(async () => {
if (!id) return;
try {
const response = await ky.get(`https://api.lanyard.rest/v1/users/${id}`).json<LanyardResponse>();
lanyard.value = response;
console.log("Lanyard data:", lanyard.value);
} catch (error) {
console.error("Error fetching lanyard data:", error);
}
}));
return (
<div
data-aos="fade-up"
data-aos-duration="1000"
class="flex gap-6 items-center bg-gradient-to-t from-stereo/15 to-stereo/5 rounded-2xl p-6 max-w-2xl shadow-2xl shadow-stereo/15"
>
<div class="relative h-30 aspect-square flex-shrink-0 rounded-full"
style={{
border: lanyard.value ? `3px solid ${
lanyard.value.data.discord_status === "online"
? "#43b581"
: lanyard.value.data.discord_status === "idle"
? "#faa61a"
: lanyard.value.data.discord_status === "dnd"
? "#f04747"
: "#7289da"
}` : "",
padding: lanyard.value ? `3px` : "0px",
}}>
<img
src={pfp ? pfp : (
lanyard.value
? `https://cdn.discordapp.com/avatars/${id}/${lanyard.value.data.discord_user.avatar}.png`
: `https://api.dicebear.com/9.x/shapes/svg?seed=${Math.random().toString(36).substring(2, 15)}.png`
)}
class="rounded-full h-full w-full object-cover"
/>
</div>
<div class="flex flex-col gap-2">
<p class="text-stereo text-9xl h-min -mb-18"></p>
<p class="text-lg text-white/90">
{quote}
</p>
<p class="text-white/60 font-light italic"> {nickname}</p>
</div>
</div>
);
});
return (
<div
data-aos="fade-up"
data-aos-duration="1000"
class="flex flex-col gap-6 items-center justify-center w-4/5"
>
<div class="flex flex-col gap-1 items-center justify-center">
<p class="text-lg text-stereo font-bold uppercase">testimonials</p>
<p class="text-2xl text-white/80">
don't just take our word for it, hear it from our users.
</p>
</div>
<div class="flex flex-col gap-6">
<Testimonial
nickname="grng"
id="829372486780715018"
quote="stereo is the best file host I've ever used, it's fast, reliable, and the interface is so clean and easy to use. I love it!"
/>
<Testimonial
nickname="hexlocation"
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!"
/>
<Testimonial
nickname="an anonymous user"
quote="stereo has changed the way I share files, it's so easy to use and the performance is top-notch. Highly recommend!"
/>
</div>
<p class="text-xl text-white/50">
and many, many more...
</p>
</div>
);
});

View file

@ -0,0 +1,40 @@
import { component$, CSSProperties, QwikIntrinsicElements, Slot } from "@builder.io/qwik";
type GradientProps = {
size: string;
from: string;
to: string;
direction?: string;
};
export default component$((props: GradientProps & QwikIntrinsicElements["div"]) => {
const {
size,
from,
to,
direction,
style: userStyle,
...rest
} = props;
const borderStyle: CSSProperties = {
padding: size,
background: `linear-gradient(${direction || "to bottom"}, ${from}, ${to})`,
display: "inline-block",
};
// Only spread userStyle if it's an object
const mergedStyle =
userStyle && typeof userStyle === "object"
? { ...borderStyle, ...userStyle }
: borderStyle;
return (
<div
style={mergedStyle}
{...rest}
>
<Slot />
</div>
);
});

View file

@ -3,6 +3,5 @@
@theme { @theme {
--font-sans: 'DM Sans', sans-serif; --font-sans: 'DM Sans', sans-serif;
--color-stereo: #ff264e;
} }
@plugin 'tailwind-scrollbar';

28
src/hooks/dropzone.ts Normal file
View file

@ -0,0 +1,28 @@
import { $, useSignal } from '@builder.io/qwik';
export const useDropzone = () => {
const highlight = useSignal(false);
const onInputChange = $(async (e: Event) => {
e.preventDefault();
});
const fileInputRef = useSignal<HTMLInputElement | undefined>(undefined);
return {
highlight,
onDragOver: $((e: DragEvent) => {
e.preventDefault();
highlight.value = true;
}),
onDragLeave: $((e: DragEvent) => {
e.preventDefault();
highlight.value = false;
}),
onInputChange,
triggerFileInput: $(() => {
fileInputRef.value?.click();
}),
fileInputRef,
};
}

27
src/hooks/mouse.ts Normal file
View file

@ -0,0 +1,27 @@
import { $, Signal, useOnDocument, useStore } from "@builder.io/qwik";
export const useMouse = () => {
const pos = useStore({ x: 0, y: 0 });
useOnDocument("mousemove", $((event: MouseEvent) => {
const { clientX, clientY } = event;
pos.x = clientX;
pos.y = clientY;
}));
return pos
}
export const useRelativeMouse = (ref: Signal<HTMLElement | undefined>) => {
const pos = useStore({ x: 0, y: 0 });
useOnDocument("mousemove", $((event: MouseEvent) => {
const el = ref.value;
if (!el) return;
const rect = el.getBoundingClientRect();
pos.x = event.clientX - rect.left;
pos.y = event.clientY - rect.top;
}));
return pos;
};

View file

@ -5,16 +5,14 @@ export const client = ky.create({
prefixUrl: '/api', prefixUrl: '/api',
credentials: 'include' credentials: 'include'
}); });
// TODO: make wrapper for apiclient fr
export const api = { export const api = {
file: async (file_id: string) => await client.get(file_id), file: async (uid: string) => await client.get<Blob>(uid),
list: async () => await client.get('list').json<StereoFile[]>(), list: async () => await client.get('list').json<StereoFile[]>(),
upload: async (file: File) => { upload: async (file: File) => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
return await client.post('upload', { body: formData }); return await client.post('upload', { body: formData });
}, },
delete: async (uid: string, file: string) => { delete: async (uid: string) => await client.delete(uid).json(),
return await client.delete(`${uid}/${file}`).json();
},
} }

View file

@ -1,6 +1,8 @@
export type StereoFile = { export type StereoFile = {
ID: string;
Name: string; Name: string;
Owner: string; Owner: string;
CreatedAt: string;
Size: number; Size: number;
CreatedAt: string;
Mime: string;
} }

View file

@ -0,0 +1,77 @@
import { component$, useVisibleTask$ } from "@builder.io/qwik";
import { routeLoader$, type DocumentHead } from "@builder.io/qwik-city";
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 { 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, "/");
});
export default component$(() => {
const files = useNanostore$<StereoFile[]>(dashboardFiles);
const loaded = useNanostore$<boolean>(areFilesLoaded);
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>
)
)}
</>
);
});
export const head: DocumentHead = {
title: "Welcome to Qwik",
meta: [
{
name: "description",
content: "Qwik site description",
},
],
};

View file

@ -1,54 +1,34 @@
import { component$, useVisibleTask$ } from "@builder.io/qwik"; /* eslint-disable qwik/jsx-img */
import type { DocumentHead } from "@builder.io/qwik-city"; import { component$ } from "@builder.io/qwik";
import Controlbar from "~/components/Controlbar"; import { DocumentHead, routeLoader$ } from "@builder.io/qwik-city";
import File from "~/components/File"; import CallToAction from "~/components/landing/CallToAction";
import { SolarUploadLinear, SvgSpinnersBarsRotateFade } from "~/components/Icons"; import Footer from "~/components/landing/Footer";
import { useNanostore$ } from "~/hooks/nanostores"; import Hero from "~/components/landing/Hero";
import { api } from "~/lib/api"; import Navbar from "~/components/landing/Navbar";
import { OAUTH_LINK } from "~/lib/constants"; import Stats from "~/components/landing/Stats";
import { areFilesLoaded, dashboardFiles } from "~/lib/stores"; import Testimonials from "~/components/landing/Testimonials";
import { StereoFile } from "~/lib/types";
// TODO: move this to dashboard/index.tsx export const useAuthCheck = routeLoader$(({ cookie, redirect: r, query }) => {
const jwt = cookie.get("jwt");
const set = Boolean(query.get("jwt_set"));
if (jwt && set) {
throw r(302, "/dashboard");
}
return {};
});
export default component$(() => { export default component$(() => {
const files = useNanostore$<StereoFile[]>(dashboardFiles);
const loaded = useNanostore$<boolean>(areFilesLoaded);
useVisibleTask$(async () => {
loaded.value = false;
files.value = await api.list();
console.log("Files loaded:", files.value);
loaded.value = true;
});
return ( return (
<> <>
<Controlbar /> <Navbar />
<a href={OAUTH_LINK}>oauth</a> <div class="flex flex-col flex-grow w-screen items-center mb-24 gap-56">
{!loaded.value ? ( <Hero />
<div class="absolute w-full h-screen flex justify-center items-center flex-col"> <Stats />
<p class="text-gray-500 text-8xl font-bold"><SvgSpinnersBarsRotateFade /></p> <Testimonials />
<p class="text-gray-700 text-2xl font-light italic">loading your files...</p> <CallToAction />
<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>
) : ( <Footer />
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">{"┻━┻︵ \\(°□°)/ ︵ ┻━┻"}</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><SolarUploadLinear /></span> button to get started</span>
</div>
)
: (
<div class="grid grid-cols-4 gap-4 p-4 mb-18">
{files.value.map((file) => (
<File key={file.Name} file={file} />
))}
</div>
)
)}
</> </>
); );
}); });

View file

@ -1,8 +1,24 @@
import { component$, Slot } from '@builder.io/qwik'; import { $, component$, Slot, useOnDocument } from '@builder.io/qwik';
import AOS from 'aos';
import 'aos/dist/aos.css';
export default component$(() => { export default component$(() => {
useOnDocument("DOMContentLoaded", $(() => {
AOS.init({
once: true,
duration: 1000,
offset: 100,
easing: 'ease-in-out',
});
}))
return ( return (
<div class="flex flex-col min-h-screen bg-neutral-950 text-white scrollbar-thin overflow-scroll"> <div
class="
flex flex-col
min-h-screen w-full overflow-x-clip
bg-neutral-950 text-white
">
<Slot /> <Slot />
</div> </div>
); );