templating dashboard & add user route to api client
This commit is contained in:
parent
0d29d59937
commit
f47a1b2226
9 changed files with 58 additions and 329 deletions
|
@ -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,9 +1,8 @@
|
|||
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";
|
||||
|
@ -23,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