use third-party module to auto-persist queue store

+ more redesign
+ convert js files to ts
This commit is contained in:
geoffrey45 2022-08-19 15:58:32 +03:00
parent 5476575d10
commit 03219166c5
20 changed files with 305 additions and 197 deletions

View File

@ -16,6 +16,6 @@
</noscript> </noscript>
<div id="app"></div> <div id="app"></div>
<!-- built files will be auto injected --> <!-- built files will be auto injected -->
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

View File

@ -4,11 +4,13 @@
<Notification /> <Notification />
<div id="app-grid"> <div id="app-grid">
<div class="l-sidebar rounded"> <div class="l-sidebar rounded">
<div class="withlogo">
<Logo /> <Logo />
<Navigation /> <Navigation />
<div class="l-album-art">
<nowPlaying />
</div> </div>
<nowPlaying />
<!-- <Playlists /> -->
</div> </div>
<NavBar /> <NavBar />
<div id="acontent" class="rounded"> <div id="acontent" class="rounded">
@ -44,6 +46,7 @@ import SearchInput from "@/components/RightSideBar/SearchInput.vue";
import BottomBar from "@/components/BottomBar/BottomBar.vue"; import BottomBar from "@/components/BottomBar/BottomBar.vue";
import { readLocalStorage, writeLocalStorage } from "@/utils"; import { readLocalStorage, writeLocalStorage } from "@/utils";
import Playlists from "./components/LeftSidebar/Playlists.vue";
const queue = useQStore(); const queue = useQStore();
const router = useRouter(); const router = useRouter();
@ -94,6 +97,11 @@ onMounted(() => {
.l-sidebar { .l-sidebar {
position: relative; position: relative;
.withlogo {
padding: 1rem;
}
.l-album-art { .l-album-art {
width: calc(100% - 2rem); width: calc(100% - 2rem);
position: absolute; position: absolute;

View File

@ -62,10 +62,12 @@
} }
.l-sidebar { .l-sidebar {
width: 17rem; width: 15rem;
grid-area: l-sidebar; grid-area: l-sidebar;
display: grid;
grid-template-rows: 1fr max-content;
gap: 1rem;
background-color: $black; background-color: $black;
padding: 1rem;
} }
.b-bar { .b-bar {

View File

@ -45,7 +45,7 @@
.bg-black { .bg-black {
background-color: $gray4; background-color: $gray4;
box-shadow: 0 0 1rem rgba(0, 0, 0, 0.1); box-shadow: 0 0 1rem rgba(0, 0, 0, 0.425)
} }
.heading { .heading {

View File

@ -4,11 +4,15 @@
<img <img
:src="paths.images.thumb + queue.currenttrack?.image" :src="paths.images.thumb + queue.currenttrack?.image"
alt="" alt=""
class="rounded" class="rounded shadow-lg"
/> />
<div class="tags"> <div class="tags">
<div class="np-artist ellip"> <div class="np-artist ellip">
<span v-for="artist in putCommas(queue.currenttrack?.artists || ['Artist'])"> <span
v-for="artist in putCommas(
queue.currenttrack?.artists || ['Artist']
)"
>
{{ artist }} {{ artist }}
</span> </span>
</div> </div>
@ -18,16 +22,27 @@
</div> </div>
</div> </div>
<Progress /> <Progress />
<!-- <div class="ex-hotkeys">
<HotKeys />
</div> -->
<div class="time">
<span class="current">{{ formatSeconds(queue.currentTime) }}</span>
<HotKeys />
<span class="full">{{
formatSeconds(queue.fullTime || queue.currenttrack.length)
}}</span>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import "@/assets/scss/BottomBar/BottomBar.scss"; import "@/assets/scss/BottomBar/BottomBar.scss";
import { formatSeconds, putCommas } from "@/utils"; import { formatSeconds, putCommas } from "@/utils";
import HotKeys from "../LeftSidebar/NP/HotKeys.vue";
import Progress from "../LeftSidebar/NP/Progress.vue"; import Progress from "../LeftSidebar/NP/Progress.vue";
import useQStore from "@/stores/queue";
import { paths } from "@/config"; import { paths } from "@/config";
import useQStore from "@/stores/queue";
const queue = useQStore(); const queue = useQStore();
</script> </script>
@ -36,10 +51,28 @@ const queue = useQStore();
.b-bar { .b-bar {
display: grid; display: grid;
grid-template-rows: 1fr max-content; grid-template-rows: 1fr max-content;
border-radius: 1rem;
gap: 1rem; gap: 1rem;
padding: 1rem; padding: 1rem;
padding-bottom: 2rem; padding-bottom: 1rem;
position: relative;
.time {
display: grid;
grid-template-columns: repeat(3, 1fr);
align-items: center;
.full {
text-align: end;
}
}
.ex-hotkeys {
position: absolute;
width: 10rem;
right: 1rem;
top: 1rem;
border-radius: $small;
}
.info { .info {
display: grid; display: grid;
@ -55,11 +88,11 @@ const queue = useQStore();
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
gap: $smaller;
.np-title { .np-title {
font-size: 1.15rem; font-size: 1.15rem;
font-weight: bold; font-weight: bold;
margin-bottom: $small;
} }
.np-artist { .np-artist {

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="hotkeys"> <div class="hotkeys rounded noscroll">
<div class="image ctrl-btn" id="previous" @click="q.playPrev"></div> <div class="image ctrl-btn" id="previous" @click="q.playPrev"></div>
<div <div
class="image ctrl-btn play-pause" class="image ctrl-btn play-pause"
@ -28,13 +28,13 @@ const q = useQStore();
justify-content: center; justify-content: center;
place-content: flex-end; place-content: flex-end;
width: 100%; width: 100%;
background-color: $gray2;
.ctrl-btn { .ctrl-btn {
height: 2.5rem; height: 2.5rem;
width: 100%; width: 100%;
background-size: 1.5rem !important; background-size: 1.5rem !important;
cursor: pointer; cursor: pointer;
border-radius: 0.5rem;
&:hover { &:hover {
background-color: $accent; background-color: $accent;

View File

@ -1,7 +1,5 @@
<template> <template>
<div class="info"> <div class="sidebar-songcard">
<div class="desc">
<div>
<router-link <router-link
:to="{ :to="{
name: 'AlbumView', name: 'AlbumView',
@ -17,17 +15,17 @@
class="l-image rounded force-lm" class="l-image rounded force-lm"
loading="lazy" loading="lazy"
/> />
</div>
</router-link>
<div id="bitrate" v-if="track?.bitrate"> <div id="bitrate" v-if="track?.bitrate">
<span v-if="track.bitrate > 1500">MASTER</span> <span v-if="track.bitrate > 1500">MASTER</span>
<span v-else-if="track.bitrate > 330">FLAC</span> <span v-else-if="track.bitrate > 330">FLAC</span>
<span v-else>MP3</span> <span v-else>MP3</span>
{{ track.bitrate }} {{ track.bitrate }}
</div> </div>
</div>
</router-link>
<div class="bottom">
<div class="title ellip">{{ props.track?.title }}</div> <div class="title ellip">{{ props.track?.title }}</div>
<div class="separator no-border"></div>
<div <div
class="artists ellip" class="artists ellip"
v-if="track?.artists && track?.artists[0] !== ''" v-if="track?.artists && track?.artists[0] !== ''"
@ -44,7 +42,6 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@ -57,3 +54,53 @@ const props = defineProps<{
track: Track | null; track: Track | null;
}>(); }>();
</script> </script>
<style lang="scss">
.sidebar-songcard {
.art {
width: 100%;
aspect-ratio: 1;
place-items: center;
margin-bottom: $small;
position: relative;
img {
width: 100%;
height: auto;
aspect-ratio: 1;
object-fit: cover;
}
#bitrate {
position: absolute;
font-size: 0.75rem;
width: max-content;
padding: 0.2rem 0.35rem;
bottom: 1rem;
left: 1rem;
background-color: $black;
border-radius: $smaller;
box-shadow: 0rem 0rem 1rem rgba(0, 0, 0, 0.438);
}
}
.bottom {
display: grid;
gap: $smaller;
}
.title {
font-weight: 900;
word-break: break-all;
}
.artists {
font-size: 0.85rem;
opacity: 0.75;
&:hover {
text-decoration: underline 1px !important;
}
}
}
</style>

View File

@ -0,0 +1,54 @@
<template>
<div class="sidebar-playlists">
<div class="header">your playlists</div>
<div class="list rounded">
<div v-for="p in pStore.playlists" class="ellip">
<router-link
:to="{
name: 'PlaylistView',
params: {
pid: p.playlistid,
},
}"
>
{{ p.name }}
</router-link>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import usePStore from "@/stores/pages/playlists";
import { onMounted } from "vue";
const pStore = usePStore();
onMounted(() => {
if (pStore.playlists.length == 0) {
pStore.fetchAll();
}
});
</script>
<style lang="scss">
.sidebar-playlists {
// outline: solid 1px;
display: grid;
grid-template-rows: max-content 1fr;
.header {
opacity: 0.5;
margin-bottom: $small;
margin-left: 1rem;
font-size: small;
}
.list {
padding: $small;
& > * {
padding: $small;
}
}
}
</style>

View File

@ -1,14 +1,5 @@
<template> <template>
<div class="now-playing-card t-center rounded"> <div class="now-playing-card t-center rounded">
<div class="headin">Now playing</div>
<div
class="button menu rounded"
@click="showContextMenu"
:class="{ context_on: context_on }"
>
<MenuSvg />
</div>
<div class="separator no-border"></div>
<div> <div>
<SongCard :track="queue.currenttrack" /> <SongCard :track="queue.currenttrack" />
<div class="l-track-time"> <div class="l-track-time">
@ -16,8 +7,8 @@
><span class="rounded">{{ formatSeconds(queue.duration.full) }}</span> ><span class="rounded">{{ formatSeconds(queue.duration.full) }}</span>
</div> </div>
<Progress /> <Progress />
<HotKeys />
</div> </div>
<HotKeys />
</div> </div>
</template> </template>
@ -65,10 +56,11 @@ const showContextMenu = (e: Event) => {
<style lang="scss"> <style lang="scss">
.now-playing-card { .now-playing-card {
padding: 1rem; padding: 1rem;
background-color: $primary;
width: 100%; width: 100%;
display: grid; display: grid;
grid-template-rows: 1fr max-content;
position: relative; position: relative;
gap: 1rem;
.l-track-time { .l-track-time {
display: flex; display: flex;
@ -96,24 +88,6 @@ const showContextMenu = (e: Event) => {
} }
} }
.headin {
font-weight: bold;
font-size: 0.9rem;
}
.button {
position: absolute;
top: $small;
cursor: pointer;
transition: all 200ms;
display: flex;
align-items: center;
padding: $smaller;
&:hover {
background-color: $accent;
}
}
.context_on { .context_on {
background-color: $accent; background-color: $accent;
@ -123,43 +97,5 @@ const showContextMenu = (e: Event) => {
right: $small; right: $small;
transform: rotate(90deg); transform: rotate(90deg);
} }
.art {
width: 100%;
aspect-ratio: 1;
place-items: center;
margin-bottom: $small;
.l-image {
height: 100%;
width: 100%;
}
}
#bitrate {
position: absolute;
font-size: 0.75rem;
width: max-content;
padding: 0.2rem 0.35rem;
top: 14rem;
left: 2rem;
background-color: $black;
border-radius: $smaller;
box-shadow: 0rem 0rem 1rem rgba(0, 0, 0, 0.438);
}
.title {
font-weight: 900;
word-break: break-all;
}
.artists {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.808);
&:hover {
text-decoration: underline 1px !important;
}
}
} }
</style> </style>

View File

@ -16,7 +16,6 @@
#logo { #logo {
height: 4.5rem !important; height: 4.5rem !important;
width: 15rem;
background-image: url(./../assets/images/logo.webp); background-image: url(./../assets/images/logo.webp);
background-size: contain; background-size: contain;
@include ximage; @include ximage;

View File

@ -1,7 +1,6 @@
<template> <template>
<div class="up-next"> <div class="up-next">
<div class="r-grid"> <div class="r-grid">
<UpNext :track="queue.tracklist[queue.next]" :playNext="queue.playNext" />
<div class="scrollable-r bg-black rounded"> <div class="scrollable-r bg-black rounded">
<QueueActions /> <QueueActions />
<div <div
@ -23,7 +22,6 @@
</TransitionGroup> </TransitionGroup>
</div> </div>
</div> </div>
<!-- <PlayingFrom :from="queue.from" /> -->
</div> </div>
</div> </div>
</template> </template>
@ -35,9 +33,7 @@ import useQStore from "@/stores/queue";
import { focusElem } from "@/utils"; import { focusElem } from "@/utils";
import TrackItem from "../shared/TrackItem.vue"; import TrackItem from "../shared/TrackItem.vue";
import PlayingFrom from "./Queue/playingFrom.vue";
import QueueActions from "./Queue/QueueActions.vue"; import QueueActions from "./Queue/QueueActions.vue";
import UpNext from "./Queue/upNext.vue";
const queue = useQStore(); const queue = useQStore();
const mouseover = ref(false); const mouseover = ref(false);
@ -66,8 +62,6 @@ onUpdated(() => {
opacity: 0; opacity: 0;
} }
/* ensure leaving items are taken out of layout flow so that moving
animations can be calculated correctly. */
.queuelist-leave-active { .queuelist-leave-active {
transition: none; transition: none;
position: absolute; position: absolute;
@ -85,10 +79,9 @@ onUpdated(() => {
.r-grid { .r-grid {
position: relative; position: relative;
height: 100%; height: 100%;
display: grid; // display: grid;
grid-template-columns: 1fr;
grid-template-rows: max-content 1fr; // gap: 1rem;
gap: 1rem;
.scrollable-r { .scrollable-r {
height: 100%; height: 100%;

View File

@ -2,13 +2,16 @@ import "./assets/scss/index.scss";
import { createPinia } from "pinia"; import { createPinia } from "pinia";
import { createApp } from "vue"; import { createApp } from "vue";
import piniaPluginPersistedstate from "pinia-plugin-persistedstate";
import App from "./App.vue"; import App from "./App.vue";
import router from "./router"; import router from "./router";
const app = createApp(App); const app = createApp(App);
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
app.use(createPinia()); app.use(pinia);
app.use(router); app.use(router);
app.mount("#app"); app.mount("#app");

View File

@ -1,5 +1,3 @@
// @ts-strict
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import state from "../composables/state"; import state from "../composables/state";
import { NotifType, useNotifStore } from "./notification"; import { NotifType, useNotifStore } from "./notification";
@ -14,29 +12,6 @@ import {
Track, Track,
} from "../interfaces"; } from "../interfaces";
function writeQueue(from: From, tracks: Track[]) {
localStorage.setItem(
"queue",
JSON.stringify({
from: from,
tracks: tracks,
})
);
}
function writeCurrent(index: number) {
localStorage.setItem("current", JSON.stringify(index));
}
function readCurrent(): number {
const current = localStorage.getItem("current");
if (current) {
return JSON.parse(current);
}
return 0;
}
function shuffle(tracks: Track[]) { function shuffle(tracks: Track[]) {
const shuffled = tracks.slice(); const shuffled = tracks.slice();
for (let i = shuffled.length - 1; i > 0; i--) { for (let i = shuffled.length - 1; i > 0; i--) {
@ -48,19 +23,15 @@ function shuffle(tracks: Track[]) {
type From = fromFolder | fromAlbum | fromPlaylist | fromSearch; type From = fromFolder | fromAlbum | fromPlaylist | fromSearch;
let audio = new Audio();
export default defineStore("Queue", { export default defineStore("Queue", {
state: () => ({ state: () => ({
progressElem: HTMLElement, progressElem: HTMLElement,
audio: new Audio(),
duration: { duration: {
current: 0, current: 0,
full: 0, full: 0,
}, },
indexes: {
current: 0,
next: 0,
previous: 0,
},
current: 0, current: 0,
next: 0, next: 0,
prev: 0, prev: 0,
@ -81,25 +52,24 @@ export default defineStore("Queue", {
this.updateCurrent(index); this.updateCurrent(index);
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
this.audio.autoplay = true; audio.autoplay = true;
this.audio.src = uri; audio.src = uri;
this.audio.oncanplaythrough = resolve; audio.oncanplaythrough = resolve;
this.audio.onerror = reject; audio.onerror = reject;
}) })
.then(() => { .then(() => {
this.duration.full = this.audio.duration; this.duration.full = audio.duration;
this.audio.play().then(() => { audio.play().then(() => {
this.playing = true; this.playing = true;
notif(track, this.playPause, this.playNext, this.playPrev); notif(track, this.playPause, this.playNext, this.playPrev);
this.audio.ontimeupdate = () => { audio.ontimeupdate = () => {
this.duration.current = this.audio.currentTime; this.duration.current = audio.currentTime;
const bg_size = const bg_size = (audio.currentTime / audio.duration) * 100;
(this.audio.currentTime / this.audio.duration) * 100;
elem.style.backgroundSize = `${bg_size}% 100%`; elem.style.backgroundSize = `${bg_size}% 100%`;
}; };
this.audio.onended = () => { audio.onended = () => {
this.playNext(); this.playNext();
}; };
}); });
@ -119,13 +89,13 @@ export default defineStore("Queue", {
}); });
}, },
playPause() { playPause() {
if (this.audio.src === "") { if (audio.src === "") {
this.play(this.current); this.play(this.current);
} else if (this.audio.paused) { } else if (audio.paused) {
this.audio.play(); audio.play();
this.playing = true; this.playing = true;
} else { } else {
this.audio.pause(); audio.pause();
this.playing = false; this.playing = false;
} }
}, },
@ -137,7 +107,7 @@ export default defineStore("Queue", {
}, },
seek(pos: number) { seek(pos: number) {
try { try {
this.audio.currentTime = pos; audio.currentTime = pos;
} catch (error) { } catch (error) {
if (error instanceof TypeError) { if (error instanceof TypeError) {
console.error("Seek error: no audio"); console.error("Seek error: no audio");
@ -152,15 +122,11 @@ export default defineStore("Queue", {
this.from = parsed.from; this.from = parsed.from;
this.tracklist = parsed.tracks; this.tracklist = parsed.tracks;
} }
this.updateCurrent(readCurrent());
}, },
updateCurrent(index: number) { updateCurrent(index: number) {
this.setCurrent(index); this.setCurrent(index);
this.updateNext(index); this.updateNext(index);
this.updatePrev(index); this.updatePrev(index);
writeCurrent(index);
}, },
updateNext(index: number) { updateNext(index: number) {
if (index == this.tracklist.length - 1) { if (index == this.tracklist.length - 1) {
@ -190,7 +156,6 @@ export default defineStore("Queue", {
if (this.tracklist !== tracklist) { if (this.tracklist !== tracklist) {
this.tracklist = []; this.tracklist = [];
this.tracklist.push(...tracklist); this.tracklist.push(...tracklist);
writeQueue(this.from, this.tracklist);
} }
}, },
playFromFolder(fpath: string, tracks: Track[]) { playFromFolder(fpath: string, tracks: Track[]) {
@ -235,7 +200,7 @@ export default defineStore("Queue", {
}, },
addTrackToQueue(track: Track) { addTrackToQueue(track: Track) {
this.tracklist.push(track); this.tracklist.push(track);
writeQueue(this.from, this.tracklist); // writeQueue(this.from, this.tracklist);
this.updateNext(this.current); this.updateNext(this.current);
}, },
playTrackNext(track: Track) { playTrackNext(track: Track) {
@ -264,16 +229,12 @@ export default defineStore("Queue", {
`Added ${track.title} to queue`, `Added ${track.title} to queue`,
NotifType.Success NotifType.Success
); );
writeQueue(this.from, this.tracklist);
}, },
clearQueue() { clearQueue() {
this.tracklist = [] as Track[]; this.tracklist = [] as Track[];
this.currentid = ""; this.currentid = "";
this.current, this.next, (this.prev = 0); this.current, this.next, (this.prev = 0);
this.from = <From>{}; this.from = <From>{};
writeCurrent(0);
writeQueue(this.from, [] as Track[]);
}, },
shuffleQueue() { shuffleQueue() {
const Toast = useNotifStore(); const Toast = useNotifStore();
@ -291,12 +252,38 @@ export default defineStore("Queue", {
this.currentid = shuffled[0].trackid; this.currentid = shuffled[0].trackid;
this.next = 1; this.next = 1;
this.prev = this.tracklist.length - 1; this.prev = this.tracklist.length - 1;
writeQueue(this.from, shuffled);
writeCurrent(0);
}, },
removeFromQueue(index: number = 0) { removeFromQueue(index: number = 0) {
this.tracklist.splice(index, 1); this.tracklist.splice(index, 1);
}, },
}, },
getters: {
getNextTrack() {
if (this.current == this.tracklist.length - 1) {
return this.tracklist[0];
} else {
return this.tracklist[this.current + 1];
}
},
getPrevTrack() {
if (this.current === 0) {
return this.tracklist[this.tracklist.length - 1];
} else {
return this.tracklist[this.current - 1];
}
},
fullTime() {
return audio.duration;
},
currentTime() {
return audio.currentTime;
},
getCurrentTrack() {
return this.tracklist[this.current];
},
getIsplaying() {
return audio.paused ? false : true;
},
},
persist: true,
}); });

View File

@ -106,7 +106,8 @@ onBeforeRouteUpdate((to, from) => {
height: 100%; height: 100%;
width: 100%; width: 100%;
object-fit: cover; object-fit: cover;
object-position: bottom; object-position: bottom right;
transition: all .25s ease;
} }
} }

View File

@ -14,8 +14,8 @@
<script setup lang="ts"> <script setup lang="ts">
import PlaylistCard from "@/components/playlists/PlaylistCard.vue"; import PlaylistCard from "@/components/playlists/PlaylistCard.vue";
import usePStore from "@/stores/pages/playlists";
import NewPlaylistCard from "@/components/playlists/NewPlaylistCard.vue"; import NewPlaylistCard from "@/components/playlists/NewPlaylistCard.vue";
import usePStore from "@/stores/pages/playlists";
const pStore = usePStore(); const pStore = usePStore();
</script> </script>

7
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@ -1,11 +1,40 @@
{ {
"compilerOptions": { "compilerOptions": {
"strict": true, "target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
// "strict": true,
"jsx": "preserve", "jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
"paths": { "paths": {
"baseUrl": ["./"], "baseUrl": ["./"],
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["src/**/*"] "include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue",
"src/**/*.svg"
],
"references": [{ "path": "./tsconfig.node.json" }]
} }
// {
// "compilerOptions": {
// "strict": false,
// "jsx": "preserve",
// "paths": {
// "baseUrl": ["./"],
// "@/*": ["./src/*"]
// }
// },
// "include": ["src/**/*"]
// }

9
tsconfig.node.json Normal file
View File

@ -0,0 +1,9 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -1,5 +1,5 @@
import vue from "@vitejs/plugin-vue";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import svgLoader from "vite-svg-loader"; import svgLoader from "vite-svg-loader";
const path = require("path"); const path = require("path");