frontend interface

This commit is contained in:
L-Nafaryus 2024-09-26 01:05:18 +05:00
parent 5878cf9d8c
commit 47168d1cbc
Signed by: L-Nafaryus
GPG Key ID: 553C97999B363D38
41 changed files with 5182 additions and 0 deletions

8
crates/frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
/target
node_modules/
*.tsbuildinfo
*.mjs
*.log
openapi.json

View File

@ -0,0 +1,14 @@
[package]
name = "elnafo-radio-frontend"
version = "0.1.0"
edition = "2021"
authors = ["L-Nafaryus <l.nafaryus@elnafo.ru>"]
[build-dependencies]
ignore = "0.4.22"
npm_rs = "1.0.0"
[dependencies]
askama = { version = "0.12.1", features = ["with-axum"] }
askama_axum = "0.4.0"
rust-embed = "8.3.0"

View File

@ -0,0 +1,2 @@
[general]
dirs = ["templates"]

20
crates/frontend/build.rs Normal file
View File

@ -0,0 +1,20 @@
use ignore::Walk;
use npm_rs::*;
fn main() {
for entry in Walk::new(".")
.filter_map(Result::ok)
.filter(|e| !e.path().is_dir())
.filter(|e| e.file_name() != "package-lock.json")
{
println!("cargo:rerun-if-changed={}", entry.path().display());
}
NpmEnv::default()
.with_node_env(&NodeEnv::from_cargo_profile().unwrap_or_default())
.init_env()
.install(None)
.run("build")
.exec()
.unwrap();
}

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/resources/assets/logo.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Materia Dev</title>
</head>
<body class="h-full text-zinc-200 font-sans ">
<div id="app" class="flex flex-col h-full"></div>
<script type="module" src="src/main.ts"></script>
</body>
</html>

4188
crates/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,40 @@
{
"name": "materia-frontend",
"version": "0.0.5",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build-check": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build": "vite build",
"type-check": "vue-tsc --build --force",
"generate-client": "openapi --input ./openapi.json --output ./src/client_old/ --client axios --name Client",
"openapi-ts": "openapi-ts --input ./openapi.json --output ./src/client/ --client @hey-api/client-axios"
},
"dependencies": {
"@catppuccin/tailwindcss": "^0.1.6",
"@hey-api/client-axios": "^0.2.3",
"autoprefixer": "^10.4.18",
"axios": "^1.6.8",
"filesize": "^10.1.6",
"pinia": "^2.1.7",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"vue": "^3.3.11",
"vue-router": "^4.3.0"
},
"devDependencies": {
"@hey-api/openapi-ts": "^0.53.0",
"@tsconfig/node18": "^18.2.2",
"@types/node": "^18.19.3",
"@vitejs/plugin-vue": "^4.5.2",
"@vitejs/plugin-vue-jsx": "^3.1.0",
"@vue/tsconfig": "^0.5.0",
"npm-run-all2": "^6.1.1",
"openapi-typescript-codegen": "^0.29.0",
"typescript": "~5.3.0",
"vite": "^5.0.10",
"vue-tsc": "^2.0.29"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,7 @@
<script setup lang="ts">
import { RouterLink, RouterView } from 'vue-router';
</script>
<template>
<RouterView />
</template>

View File

@ -0,0 +1,69 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-ctp-crust;
font-family: Inter,sans-serif;
font-weight: 400;
}
a {
@apply text-ctp-lavender;
}
.input {
@apply w-full pl-3 pr-3 pt-2 pb-2 rounded border bg-ctp-mantle border-ctp-overlay0 hover:border-ctp-overlay1 focus:border-ctp-lavender text-ctp-text outline-none;
}
.input-file {
@apply block w-full border rounded cursor-pointer bg-ctp-base border-ctp-surface0 text-ctp-subtext0 focus:outline-none;
@apply file:bg-ctp-mantle file:border-ctp-surface0 file:mr-5 file:py-2 file:px-3 file:h-full file:border-y-0 file:border-l-0 file:border-r file:text-ctp-blue hover:file:cursor-pointer hover:file:bg-ctp-base;
}
label {
@apply text-ctp-text;
}
.button {
@apply pt-1 pb-1 pl-3 pr-3 sm:pt-2 sm:pb-2 sm:pl-5 sm:pr-5 rounded bg-ctp-peach border hover:bg-ctp-overlay0/20 text-ctp-blue cursor-pointer;
}
.link-button {
@apply button text-ctp-base cursor-pointer border-none;
}
.hline {
@apply border-t border-ctp-surface0 ml-0 mr-0;
}
h1 {
@apply text-ctp-text pt-5 pb-5 border-b border-ctp-overlay0 mb-5;
}
label {
@apply text-ctp-text;
}
.icon {
@apply inline-block select-none text-center overflow-visible w-6 h-6 stroke-ctp-text;
}
}
@layer utilities {
.bg-grid {
background:
linear-gradient(180deg, rgba(0, 0, 0, 0) 0px, rgba(187, 65, 143, 1) 10%,
rgba(187, 65, 143, 1) 2px, rgba(0, 0, 0, 0) 0px),
linear-gradient(90deg, rgba(0, 0, 0, 0) 0px, rgba(187, 65, 143, 1) 10%,
rgba(187, 65, 143, 1) 2px, rgba(0, 0, 0, 0) 0px);
background-size: 2em 4em, 6em 2em;
transform: perspective(500px) rotateX(60deg) scale(0.5);
transform-origin: 50% 0%;
z-index: -1;
@apply absolute w-[250%] -left-[75%] h-[200%];
}
}

View File

@ -0,0 +1,44 @@
import { createClient, createConfig, type Options } from '@hey-api/client-axios';
export const client = createClient(createConfig());
export type StationId = string;
export enum StationStatus {
Online = "Online",
Offline = "Offline",
Receive = "Receive"
};
export enum Playback {
Stopped = "Stopped",
Playing = "Playing",
Paused = "Paused"
};
export type StationInfo = {
id: StationId;
name: string;
status: StationStatus;
url: string | null;
location: string | null;
genre: string | null;
playback: Playback | null;
};
export type SongInfo = {
artist: string | null;
title: string | null;
tags: Array<[string, string]>;
};
export const stationsInfo = <ThrowOnError extends boolean = false>(options?: Options<unknown, ThrowOnError>) => { return (options?.client ?? client).get<StationInfo[], unknown, ThrowOnError>({
...options,
url: '/api/stations'
}); };
export const songStatus = <ThrowOnError extends boolean = false>(options?: Options<unknown, ThrowOnError>) => { return (options?.client ?? client).get<SongInfo | null, unknown, ThrowOnError>({
...options,
url: '/api/status'
}); };

View File

@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
<path
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
/>
</svg>
</template>

View File

@ -0,0 +1,40 @@
<script setup lang="ts">
import { computed } from "vue";
const { value } = defineProps(["value"]);
const handleError = (e) => {
let entries = [];
let titlecase = (str: string) => {
if (!str) return str;
return str.charAt(0).toUpperCase() + str.slice(1);
};
if (e?.response) {
if (e.response.status == 422) {
for (let detail of e.response.data.detail) {
entries.push(titlecase(detail.msg));
}
} else {
if (e.response.data?.detail) {
entries.push(e.response.data.detail);
} else {
entries.push(e.response.data);
}
}
} else {
entries.push(e);
}
return entries;
};
</script>
<template>
<section v-if="value" class="mt-1 mb-1">
<p v-for="entry in handleError(value)"
class="text-center pt-3 pb-3 bg-ctp-red/25 rounded border border-ctp-red text-ctp-red">
{{ entry }}
</p>
</section>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4">
<path
d="M4.61824 16.3959C5.01386 16 5.55038 15.7776 6.10981 15.7776C6.66932 15.7777 7.20584 16.0001 7.60137 16.396C7.99699 16.7919 8.21926 17.3289 8.21926 17.8888C8.21926 18.4487 7.99699 18.9857 7.60137 19.3816C7.20584 19.7776 6.66932 20 6.10981 20C5.55038 20 5.01386 19.7776 4.61824 19.3818C4.22236 18.9859 4 18.4489 4 17.8888C4 17.3288 4.22236 16.7917 4.61824 16.3959ZM20.4547 19.9436C20.0442 19.9436 19.6505 19.7803 19.3603 19.4897C19.07 19.1992 18.907 18.8051 18.9071 18.3943C18.9111 16.6164 18.5563 14.8561 17.8641 13.2187C16.8486 10.8089 15.1447 8.75272 12.9663 7.30797C10.7879 5.86328 8.23187 5.0945 5.61851 5.09759C5.20772 5.09819 4.81364 4.93521 4.52292 4.64468C4.23226 4.35423 4.06898 3.95992 4.06898 3.5488C4.06898 3.13768 4.23226 2.74346 4.52292 2.45292C4.81366 2.16238 5.20772 1.99948 5.61851 2.00001C7.77018 1.99785 9.901 2.42086 11.8889 3.24482C13.8767 4.06878 15.6824 5.27739 17.2025 6.80155C18.7253 8.32267 19.9332 10.1297 20.7564 12.1192C21.5796 14.1087 22.0022 16.241 22 18.3945C22.001 18.8051 21.8387 19.1994 21.5487 19.4901C21.2588 19.7808 20.8652 19.9439 20.4547 19.9436ZM13.6308 19.9436C13.2202 19.9436 12.8265 19.7803 12.5363 19.4897C12.246 19.1992 12.083 18.8051 12.0831 18.3943C12.0834 17.2585 11.7848 16.1426 11.2174 15.1593C10.6501 14.1756 9.83395 13.3589 8.851 12.7911C7.86813 12.2233 6.75325 11.9247 5.61835 11.925C5.06539 11.925 4.55444 11.6297 4.27792 11.1504C4.00148 10.6712 4.00148 10.0807 4.27792 9.60148C4.55444 9.12219 5.06539 8.82692 5.61835 8.82692C7.49753 8.82761 9.33491 9.38206 10.9011 10.4211C12.4674 11.4602 13.6932 12.938 14.4256 14.6698C14.9251 15.8477 15.1811 17.1147 15.178 18.3942C15.1781 18.8051 15.0152 19.1991 14.7249 19.4897C14.4347 19.7802 14.0413 19.9436 13.6308 19.9436Z"
fill="currentColor"></path>
</svg>
</template>

View File

@ -0,0 +1,11 @@
<template>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.1845 6.49999C12.1845 7.32842 11.513 7.99999 10.6845 7.99999C9.8561 7.99999 9.18452 7.32842 9.18452 6.49999C9.18452 5.67156 9.8561 4.99999 10.6845 4.99999C11.513 4.99999 12.1845 5.67156 12.1845 6.49999Z"
fill="currentColor" />
<path d="M12 18.7213L12 10.7213L9 10.7213" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M12 18.7213H14" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg stroke="currentColor" fill="none" stroke-width="2" viewBox="0 0 24 24" stroke-linecap="round"
stroke-linejoin="round" class="w-4" height="1em" width="1em" xmlns="http://www.w3.org/2000/svg">
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
</template>

View File

@ -0,0 +1,48 @@
<script setup lang="ts">
import { defineProps, defineEmits, ref } from "vue";
const props = defineProps({
isOpen: Boolean,
});
const emit = defineEmits(["close-modal"]);
const closeModal = (action) => {
emit("close-modal", action);
};
</script>
<template>
<div v-if="isOpen" class="modal-mask">
<div class="modal-wrapper">
<div class="modal-container">
<div class="flex mb-4">
<slot name="header"></slot>
<button class="mr-0 ml-auto button" @click="closeModal">x</button>
</div>
<div>
<slot name="content"></slot>
</div>
<div class="flex mt-4">
<slot name="footer"></slot>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.modal-mask {
@apply fixed z-[666] top-0 left-0 w-full h-full;
@apply bg-ctp-crust bg-opacity-50;
}
.modal-wrapper {}
.modal-container {
@apply flex-grow w-full lg:w-[calc(100%-100px)] max-w-[1000px] ml-auto mr-auto lg:mt-20 lg:mb-20 p-5;
@apply bg-ctp-crust shadow-ctp-overlay0 border rounded border-ctp-surface1 text-ctp-text;
@apply h-screen lg:h-full;
}
</style>

View File

@ -0,0 +1,12 @@
<template>
<div class="relative h-12">
<nav class="absolute w-full h-full flex justify-between items-center m-0 pl-3 pr-3 bg-ctp-peach">
<div class="items-center m-0 flex">
<slot name="left"></slot>
</div>
<div class="items-center m-0 flex">
<slot name="right"></slot>
</div>
</nav>
</div>
</template>

View File

@ -0,0 +1,6 @@
<template>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M5.163 3.819C5 4.139 5 4.559 5 5.4v13.2c0 .84 0 1.26.163 1.581a1.5 1.5 0 0 0 .656.655c.32.164.74.164 1.581.164h.2c.84 0 1.26 0 1.581-.163a1.5 1.5 0 0 0 .656-.656c.163-.32.163-.74.163-1.581V5.4c0-.84 0-1.26-.163-1.581a1.5 1.5 0 0 0-.656-.656C8.861 3 8.441 3 7.6 3h-.2c-.84 0-1.26 0-1.581.163a1.5 1.5 0 0 0-.656.656zm9 0C14 4.139 14 4.559 14 5.4v13.2c0 .84 0 1.26.164 1.581a1.5 1.5 0 0 0 .655.655c.32.164.74.164 1.581.164h.2c.84 0 1.26 0 1.581-.163a1.5 1.5 0 0 0 .655-.656c.164-.32.164-.74.164-1.581V5.4c0-.84 0-1.26-.163-1.581a1.5 1.5 0 0 0-.656-.656C17.861 3 17.441 3 16.6 3h-.2c-.84 0-1.26 0-1.581.163a1.5 1.5 0 0 0-.655.656z" />
</svg>
</template>

View File

@ -0,0 +1,7 @@
<template>
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="w-4 h-4">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M5.4855 3.14251C5.78671 2.96178 6.16065 2.95235 6.47059 3.11765L21.4706 11.1176C21.7965 11.2914 22 11.6307 22 12C22 12.3693 21.7965 12.7086 21.4706 12.8824L6.47059 20.8824C6.16065 21.0477 5.78671 21.0382 5.4855 20.8575C5.1843 20.6768 5 20.3513 5 20V4C5 3.64874 5.1843 3.32323 5.4855 3.14251Z"
fill="currentcolor"></path>
</svg>
</template>

View File

@ -0,0 +1,68 @@
<script setup lang="ts">
import PlayIcon from "@/components/PlayIcon.vue";
import PauseIcon from "@/components/PauseIcon.vue";
import VinylIcon from "@/components/VinylIcon.vue";
import { api, store } from "@";
import { ref, onUpdated, onMounted } from "vue";
const player = store.usePlayer();
const audioRef = ref(null);
onMounted(() => {
player.register(audioRef.value);
});
</script>
<template>
<div class="flex sticky w-full mx-auto bottom-2 px-2">
<div class="flex rounded-md m-2 bg-ctp-base mx-auto w-full border-2 border-ctp-mantle">
<div class="flex items-center justify-center">
<div class="h-20 flex rounded-l-md aspect-square items-center justify-center bg-ctp-overlay0">
<VinylIcon class="p-1" />
</div>
</div>
<div class="w-full flex flex-col justify-center px-4 py-4">
<span class="flex-grow font-bold">{{ player.station ? "ASD" : "Unknown" }}</span>
<span class="text-ctp-subtext0">Location</span>
</div>
<div class="flex w-8 h-20 p-2">
<input type="range" class="slider" />
</div>
<div class="flex items-center">
<button @click="player.toggle()"
class="h-full w-20 inline-flex rounded items-center justify-center bg-ctp-mantle hover:bg-ctp-mantle/50">
<PlayIcon v-if="!player.playing" class="text-ctp-peach w-8 h-8" />
<PauseIcon v-else class="text-ctp-peach w-8 h-8" />
</button>
</div>
</div>
<audio class="hidden" ref="audioRef" />
</div>
</template>
<style>
.slider[type=range] {
-webkit-appearance: none;
writing-mode: vertical-lr;
direction: rtl;
vertical-align: middle;
@apply appearance-none bg-ctp-surface0 hover:bg-ctp-surface1 w-4 rounded-full;
}
.slider::-webkit-slider-thumb {
@apply appearance-none bg-ctp-peach rounded-full cursor-pointer w-4 border-none;
}
.slider::-moz-range-thumb {
@apply appearance-none bg-ctp-peach rounded-full cursor-pointer w-4 border-none;
}
.slider::-webkit-progress-value {
@apply bg-ctp-peach/20 w-4 rounded-full;
}
.slider::-moz-range-progress {
@apply bg-ctp-peach/20 w-4 rounded-full;
}
</style>

View File

@ -0,0 +1,75 @@
<script setup lang="ts">
import { api } from "@";
import PlayIcon from "@/components/PlayIcon.vue";
import ExternalIcon from "@/components/ExternalIcon.vue";
import LocationIcon from "@/components/LocationIcon.vue"
import TicketIcon from "@/components/TicketIcon.vue";
import VinylIcon from "@/components/VinylIcon.vue";
import InfoIcon from "@/components/InfoIcon.vue";
import Tooltip from "@/components/Tooltip.vue";
import { store } from "@";
const player = store.usePlayer();
const { stationInfo } = defineProps({
stationInfo: api.StationInfo,
});
const status = () => {
if (stationInfo.status === api.StationStatus.Receive) {
return api.StationStatus.Online;
}
if (stationInfo.playback === api.Playback.Stopped || stationInfo.playback === api.Playback.Paused) {
return api.StationStatus.Offline;
}
return stationInfo.status;
};
</script>
<template>
<div class="flex rounded-xl m-2 bg-ctp-base">
<div class="flex items-center justify-center">
<div class="h-20 sm:h-36 flex rounded-l-xl aspect-square items-center justify-center bg-ctp-maroon/50">
<VinylIcon class="p-1" />
</div>
</div>
<div class="w-full flex flex-col justify-center px-4 space-y-2">
<div class="flex h-8 sm:h-10 items-center">
<span class="flex-grow font-bold text-ctp-peach text-xl sm:text-2xl">{{ stationInfo.name }}</span>
</div>
<div class="hidden sm:flex flex-col space-y-1">
<div v-if="stationInfo.location" class="flex items-center space-x-1">
<LocationIcon class="text-ctp-subtext0" />
<span class="text-ctp-subtext0">{{ stationInfo.location }}</span>
</div>
<div v-if="stationInfo.genre" class="flex items-center space-x-1">
<TicketIcon class="text-ctp-subtext0" />
<span class="text-ctp-subtext0">{{ stationInfo.genre }}</span>
</div>
</div>
</div>
<div class="flex flex-col justify-center pr-4 space-y-2">
<div class="flex items-center justify-center space-x-2">
<button
class="p-1.5 sm:p-2.5 w-8 sm:w-10 inline-flex rounded-md items-center justify-center bg-ctp-mantle hover:bg-ctp-mantle/50">
<Tooltip :text="status()">
<InfoIcon class="text-ctp-peach" />
</Tooltip>
</button>
<button
class="p-1.5 sm:p-2.5 w-8 sm:w-10 inline-flex rounded-md items-center justify-center bg-ctp-mantle hover:bg-ctp-mantle/50">
<ExternalIcon class="text-ctp-peach" />
</button>
<button v-if="status()" @click="player.load(stationInfo, true)"
class="p-1.5 sm:p-2.5 w-8 sm:w-10 inline-flex rounded-md items-center justify-center bg-ctp-mantle hover:bg-ctp-mantle/50">
<PlayIcon class="text-ctp-peach" />
</button>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,13 @@
<template>
<svg viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg" class="w-4 h-4"
xmlns:xlink="http://www.w3.org/1999/xlink">
<path
d="M24.782 1.606h-7.025l-16.151 16.108 12.653 12.681 16.135-16.093v-7.096l-5.613-5.6zM29.328 13.859l-15.067 15.027-11.147-11.171 15.083-15.044h6.143l4.988 4.976v6.211z"
fill="currentcolor">
</path>
<path
d="M21.867 7.999c0 1.173 0.956 2.128 2.133 2.128s2.133-0.954 2.133-2.128c0-1.174-0.956-2.129-2.133-2.129s-2.133 0.955-2.133 2.129zM25.066 7.999c0 0.585-0.479 1.062-1.066 1.062s-1.066-0.476-1.066-1.062c0-0.586 0.478-1.063 1.066-1.063s1.066 0.477 1.066 1.063z"
fill="currentcolor">
</path>
</svg>
</template>

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import { ref } from "vue";
const { text } = defineProps(["text"]);
const isShow = ref(false);
const posX = ref(0);
const posY = ref(0);
const anchor = ref(null);
const showTooltip = (event, value) => {
event.preventDefault();
if (isShow.value)
return;
posX.value = anchor.value.getBoundingClientRect().left;
posY.value = anchor.value.getBoundingClientRect().top;
isShow.value = true;
};
const closeTooltip = () => {
isShow.value = false;
};
</script>
<template>
<div ref="anchor" @mouseover="showTooltip" @mouseleave="closeTooltip" style="display: unset">
<slot></slot>
<div class="tooltip" :style="{ top: posY + 32 + 'px', left: posX + 16 + 'px' }" v-if="isShow">
{{
text
}}
</div>
</div>
</template>
<style>
.tooltip {
@apply hidden sm:inline z-[666] absolute bg-ctp-mantle rounded p-2 border border-ctp-surface0 max-w-[600px];
overflow-wrap: break-all;
word-break: break-all;
white-space: pre-wrap;
}
</style>

View File

@ -0,0 +1,42 @@
<template>
<svg viewBox="0 0 187.62987 187.62987" version="1.1" id="svg1" xml:space="preserve"
xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg">
<defs id="defs1" />
<g id="layer1" transform="translate(-254,-327.14301)">
<g id="g22" style="display:inline" transform="translate(-12.05508,278.08297)">
<path id="path29"
style="fill:#000000;fill-opacity:0.500678;stroke:none;stroke-width:1;stroke-dasharray:none;stroke-opacity:0.250793"
d="M 359.87002,49.060055 A 93.814941,93.814941 0 0 0 266.05508,142.875 93.814941,93.814941 0 0 0 359.87002,236.68995 93.814941,93.814941 0 0 0 453.68497,142.875 93.814941,93.814941 0 0 0 359.87002,49.060055 Z m 0,92.013505 a 1.801514,1.801514 0 0 1 1.80144,1.80144 1.801514,1.801514 0 0 1 -1.80144,1.80144 1.801514,1.801514 0 0 1 -1.80144,-1.80144 1.801514,1.801514 0 0 1 1.80144,-1.80144 z" />
<path id="path31"
style="fill:#000000;fill-opacity:0.500678;stroke:none;stroke-width:1.10062;stroke-dasharray:none;stroke-opacity:1"
d="m 359.87002,111.42486 a 31.450164,31.450164 0 0 0 -31.45014,31.45014 31.450164,31.450164 0 0 0 31.45014,31.45014 31.450164,31.450164 0 0 0 31.45014,-31.45014 31.450164,31.450164 0 0 0 -31.45014,-31.45014 z m 0,29.46744 a 1.9827772,1.9827772 0 0 1 1.9827,1.9827 1.9827772,1.9827772 0 0 1 -1.9827,1.9827 1.9827772,1.9827772 0 0 1 -1.9827,-1.9827 1.9827772,1.9827772 0 0 1 1.9827,-1.9827 z" />
<g id="g21" style="fill:none;fill-opacity:0.500678;stroke:#676767;stroke-opacity:0.304089">
<path
d="m 293.5304,142.70602 c -0.005,0.0563 -0.0101,0.11265 -0.015,0.16898 m 0,0 c 0,36.64663 29.70797,66.3546 66.3546,66.35461 m 0,0 c 0.0563,-0.005 0.11267,-0.01 0.16899,-0.015 m 0,-8.90592 c -0.0563,0.005 -0.11265,0.0101 -0.16899,0.015 M 302.42131,142.875 c 0.005,-0.0563 0.01,-0.11266 0.015,-0.16898"
style="fill:none;fill-opacity:0.500678;stroke:#676767;stroke-opacity:0.304089" id="path21" />
<path
d="m 359.87006,218.07661 c 0.0563,-0.004 0.11267,-0.009 0.16899,-0.0134 m 0,-8.84856 c -0.0563,0.005 -0.11265,0.0101 -0.16899,0.015 m -66.3546,-66.35461 c 0.005,-0.0563 0.01,-0.11266 0.015,-0.16898 m -8.84861,-4e-5 c -0.005,0.0563 -0.009,0.11265 -0.0134,0.16898 m 0,0 c 10e-6,41.5327 33.66891,75.2016 75.20161,75.20161"
style="fill:none;fill-opacity:0.500678;stroke:#676767;stroke-opacity:0.304089" id="path20" />
<path
d="m 284.66841,142.87497 c 0.004,-0.0563 0.009,-0.11266 0.0134,-0.16898 m -8.72505,0 c -0.005,0.0563 -0.009,0.11265 -0.0134,0.16898 m 0,0 c -4e-5,46.35143 37.57523,83.9267 83.92666,83.92666 m 0,0 c 0.0563,-0.004 0.11266,-0.009 0.16899,-0.0134 m 0,0 v -1e-5 m 0,-8.72504 c -0.0563,0.005 -0.11265,0.009 -0.16899,0.0134"
style="fill:none;fill-opacity:0.500678;stroke:#676767;stroke-opacity:0.304089" id="path19" />
<path
d="m 302.43632,142.70602 c -0.005,0.0563 -0.0101,0.11265 -0.015,0.16898 m 0,0 c 1.3e-4,31.72798 25.72071,57.44856 57.44869,57.44869 m 0,0 c 0.0563,-0.005 0.11267,-0.01 0.16899,-0.015 m 0,-9.79165 c -0.0563,0.006 -0.11265,0.0111 -0.16899,0.0165 m 0,0 c -26.32115,8e-5 -47.65867,-21.33744 -47.65859,-47.65859 m 0,0 c 0.005,-0.0563 0.0109,-0.11266 0.0165,-0.16898 m 0,0 5e-5,5e-5"
style="fill:none;fill-opacity:0.500678;stroke:#676767;stroke-opacity:0.304089" id="path12" />
<path
d="m 359.87006,85.426307 c -0.0563,0.0049 -0.11266,0.0099 -0.16898,0.01499 m 0,0 -4e-5,-1e-6 m 0,9.791651 c 0.0563,-0.0056 0.11264,-0.01113 0.16898,-0.01654 m 0,0 c 26.32115,-7.9e-5 47.65867,21.337443 47.65859,47.658593"
style="fill:none;fill-opacity:0.500678;stroke:#676767;stroke-opacity:0.304089" id="path8" />
<path
d="m 359.87002,76.52039 c -0.0563,0.0049 -0.11266,0.0099 -0.16898,0.01499 m 0,8.905916 c 0.0563,-0.0051 0.11265,-0.01008 0.16898,-0.01499 m 0,0 c 31.72819,-1.6e-4 57.44908,25.720504 57.44921,57.448694"
style="fill:none;fill-opacity:0.500678;stroke:#676767;stroke-opacity:0.304089" id="path6" />
<path
d="m 359.70104,76.53538 c 0.0563,-0.0051 0.11265,-0.01006 0.16898,-0.01499 m 0,0 c 36.64664,2e-6 66.35461,29.70797 66.35461,66.35461 m 0,0 c -0.005,0.0563 -0.01,0.11266 -0.015,0.16898 m 8.84856,0 c 0.005,-0.0563 0.009,-0.11265 0.0134,-0.16898 m 0,0 c -1e-5,-41.5327 -33.66891,-75.201604 -75.20161,-75.201615 m 0,0 c -0.0563,0.0044 -0.11266,0.0089 -0.16898,0.01344 m 0,0 h 4e-5"
style="fill:none;fill-opacity:0.500678;stroke:#676767;stroke-opacity:0.304089" id="path4" />
<path
d="m 435.07163,142.875 c -0.004,0.0563 -0.009,0.11265 -0.0134,0.16898 m 8.72505,0 c 0.005,-0.0563 0.009,-0.11265 0.0134,-0.16898 m 0,0 c 4e-5,-46.351434 -37.57523,-83.926703 -83.92666,-83.926664 m 0,0 c -0.0563,0.0044 -0.11266,0.0089 -0.16898,0.01344 m 0,8.725049 c 0.0563,-0.0045 0.11265,-0.009 0.16898,-0.01344"
style="fill:none;fill-opacity:0.500678;stroke:#676767;stroke-opacity:0.304089" id="path2" />
</g>
</g>
</g>
</svg>
</template>

View File

@ -0,0 +1,9 @@
export * as plugins from "@/plugins";
export { router } from "@/router";
export { client } from "@/client.ts";
export * as api from "@/client.ts";
export * as schemas from "@/client/schemas.gen.ts";
export * as api_types from "@/client/types.gen.ts";
export * as store from "@/store";
export * as style from "@/assets/style.css";
export * as types from "@/types";

View File

@ -0,0 +1,21 @@
import App from "@/App.vue";
import { createApp } from "vue";
import { createPinia } from "pinia";
import { plugins, router, client, style } from "@";
const debug = import.meta.hot;
client.setConfig({
baseURL: debug ? "http://localhost:54605" : "/",
withCredentials: true,
});
createApp(App)
.use(createPinia())
.use(router)
.directive("click-outside", plugins.clickOutside)
.directive("tooltip", plugins.tooltip)
.mount('#app');

View File

@ -0,0 +1,34 @@
export const clickOutside = {
beforeMount: function(element: any, binding: any) {
element.clickOutsideEvent = function(event: any) {
if (!(element == event.target || element.contains(event.target))) {
binding.value(event);
}
};
document.body.addEventListener("click", element.clickOutsideEvent);
document.body.addEventListener("contextmenu", element.clickOutsideEvent);
},
unmounted: function(element: any) {
document.body.removeEventListener("click", element.clickOutsideEvent);
document.body.removeEventListener("contextmenu", element.clickOutsideEvent);
}
};
export const tooltip = {
beforeMount: function (element: any, binding: any) {
element.tooltip = function (event) {
let target = event.target;
if (target.offsetWidth < target.scrollWidth) {
target.setAttribute('title', binding.value?.text ? binding.value.text : event.target.textContent);
} else {
target.hasAttribute('title') && target.removeAttribute('title');
}
};
document.body.addEventListener('mouseover', element.tooltip);
},
unmounted: function(element: any) {
document.body.removeEventListener("mouseover", element.tooltip);
}
};

View File

@ -0,0 +1 @@
export * from "@/plugins/directives";

View File

@ -0,0 +1,19 @@
import { createRouter, createWebHistory, useRoute } from "vue-router";
import { store, api, schemas } from "@";
export const router = createRouter({
history: createWebHistory(),
routes: [
{
path: "/", name: "home",
component: () => import("@/views/Home.vue"),
},
{
path: "/:pathMatch(.*)*", name: "not-found",
component: () => import("@/views/NotFound.vue")
}
]
});
export default router;

View File

@ -0,0 +1,49 @@
import { defineStore } from "pinia";
import { ref, type Ref } from "vue";
import { useRoute } from "vue-router";
import axios, { CancelToken } from "axios";
import { api } from "@";
export const usePlayer = defineStore("player", () => {
const station = ref(null);
const playing = ref(false);
const instance = ref(null);
const register = (_instance) => {
instance.value = _instance;
};
const load = (station: api.StationInfo, start: bool = false) => {
station.value = station;
instance.value.src = station.value.url;
instance.value.load();
if (start) {
play();
}
};
const play = () => {
if (!instance.value.src)
return;
instance.value.play();
playing.value = true;
};
const pause = () => {
if (!instance.value.src)
return;
instance.value.pause();
playing.value = false;
};
const toggle = () => {
if (playing.value) {
pause();
} else {
play();
}
};
return { station, playing, load, instance, register, play, pause, toggle };
});

View File

@ -0,0 +1,30 @@
import { api_types } from "@";
import { CancelToken } from "axios";
export type Error = object;
export type RepositoryItemMeta = {
selected: bool;
clickCount: number;
clickTimer: (object | null);
};
export type RepositoryItemType = "directory" | "file";
export type RepositoryItemInfo = {
info: (api_types.DirectoryInfo | api_types.FileInfo);
meta: RepositoryItemMeta;
type: RepositoryItemType;
};
export type RepositoryContent = RepositoryItemInfo[];
export type UploadFileStatus = "success" | "fail" | "transfer" | "idle";
export type UploadFile = {
content: File;
status: UploadFileStatus;
progress: number;
cancel: CancelToken | null;
error: string | null;
};

View File

@ -0,0 +1,40 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { router, api, store } from "@";
import NavBar from "@/components/NavBar.vue";
import DocumentationIcon from "@/components/DocumentationIcon.vue";
import Player from "@/components/Player.vue";
import Error from "@/components/Error.vue";
</script>
<template>
<div class="flex-grow">
<NavBar>
<template #left>
<RouterLink class="link-button" to="/">Home</RouterLink>
</template>
<template #right>
<RouterLink class="link-button" to="/auth/signin">Sign In</RouterLink>
</template>
</NavBar>
<main class="w-full max-w-[1000px] mx-auto">
<slot></slot>
</main>
</div>
<Player />
<footer class="flex justify-between pb-2 pt-2 pl-5 pr-5 bg-ctp-mantle">
<a href="https://vcs.elnafo.ru/L-Nafaryus" class="text-ctp-peach">Made by L-Nafaryus, 2024</a>
<div>
</div>
<a href="/api/docs" class="flex justify-center items-center space-x-1 text-ctp-peach">
<DocumentationIcon />
<span>API</span>
</a>
</footer>
</template>

View File

@ -0,0 +1,45 @@
<script setup lang="ts">
import Base from "@/views/Base.vue";
import PlayIcon from "@/components/PlayIcon.vue";
import ExternalIcon from "@/components/ExternalIcon.vue";
import LocationIcon from "@/components/LocationIcon.vue";
import Error from "@/components/Error.vue";
import Station from "@/components/Station.vue";
import { api } from "@";
import { ref, onMounted, onUnmounted } from "vue";
const error = ref(null);
const stations = ref(null);
const update = ref(null);
const stationsInfo = async () => {
error.value = null;
await api.stationsInfo({ throwOnError: true })
.then(async stationsInfo => {
stations.value = stationsInfo.data;
console.log(stations.value);
})
.catch(err => {
stations.value = null;
error.value = "Failed to get stations list";
});
};
onMounted(async () => {
await stationsInfo();
update.value = setInterval(stationsInfo, 10000);
});
onUnmounted(() => {
clearInterval(update.value);
});
</script>
<template>
<Base>
<Error :value="error" />
<Station :stationInfo="station" v-for="station in stations" />
</Base>
</template>

View File

@ -0,0 +1,10 @@
<script setup lang="ts">
import Base from "@/views/Base.vue";
import Error from "@/components/Error.vue";
</script>
<template>
<Base>
<Error>404 Not Found</Error>
</Base>
</template>

View File

@ -0,0 +1,32 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{vue,ts,js}"],
theme: {
extend: {
keyframes: {
"border-spin": {
"100%": {
transform: "rotate(-360deg)",
}
},
"border-roll": {
"100%": {
"background-position": "200% 0",
}
}
},
animation: {
"border-spin": "border-spin 7s linear infinite",
"border-roll": "border-roll 5s linear infinite"
}
},
},
plugins: [
require("@catppuccin/tailwindcss")({
prefix: "ctp",
defaultFlavour: "macchiato"
})
],
}

View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/resources/assets/logo.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Materia</title>
<script type="module" crossorigin src="/resources/assets/index.js"></script>
<link rel="stylesheet" crossorigin href="/resources/assets/index.css">
</head>
<body class="h-full bg-zinc-900 text-zinc-200 font-sans">
<div id="{{ view }}" class="flex flex-col h-full"></div>
</body>
</html>

View File

@ -0,0 +1,21 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": [
"env.d.ts",
"src/**/*",
"src/**/*.vue"
],
"exclude": [
"src/**/__tests__/*"
],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
]
}
}
}

View File

@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.vite.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View File

@ -0,0 +1,20 @@
{
"extends": "@tsconfig/node18/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"composite": true,
"noEmit": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"types": [
"node",
"vite"
]
}
}

View File

@ -0,0 +1,27 @@
import { fileURLToPath, URL } from "node:url"
import { defineConfig } from "vite"
import vue from "@vitejs/plugin-vue"
import vueJsx from "@vitejs/plugin-vue-jsx"
import path from "path";
export default defineConfig({
plugins: [
vue(),
vueJsx(),
],
build: {
//outDir: path.resolve(__dirname, "./frontend"),
rollupOptions: {
output: {
entryFileNames: "resources/assets/[name].js",
assetFileNames: "resources/assets/[name][extname]",
chunkFileNames: "resources/assets/[name].js"
}
}
},
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url))
}
}
})