mirror of
https://github.com/JuLi0n21/pwa-player.git
synced 2026-04-19 15:30:05 +00:00
swap to generated api code and composables
This commit is contained in:
2
frontend/.env
Normal file
2
frontend/.env
Normal file
@@ -0,0 +1,2 @@
|
||||
PROXY_URL=
|
||||
BACKEND_URL=http://localhost:8080
|
||||
2
frontend/.gitignore
vendored
2
frontend/.gitignore
vendored
@@ -28,3 +28,5 @@ coverage
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
generated/
|
||||
|
||||
580
frontend/api-specs/osu_music.swagger.json
Normal file
580
frontend/api-specs/osu_music.swagger.json
Normal file
@@ -0,0 +1,580 @@
|
||||
{
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"title": "osu_music.proto",
|
||||
"version": "version not set"
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "MusicBackend"
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"paths": {
|
||||
"/api/v1/artist/{artist}": {
|
||||
"get": {
|
||||
"operationId": "MusicBackend_Artist",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1ArtistResponse"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "artist",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"MusicBackend"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/collections": {
|
||||
"get": {
|
||||
"operationId": "MusicBackend_Collections",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1CollectionResponse"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "index",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"MusicBackend"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/favorites": {
|
||||
"get": {
|
||||
"operationId": "MusicBackend_Favorite",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1FavoriteResponse"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "query",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"MusicBackend"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/recent": {
|
||||
"get": {
|
||||
"operationId": "MusicBackend_Recent",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1RecentResponse"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"MusicBackend"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/search": {
|
||||
"get": {
|
||||
"operationId": "MusicBackend_Search",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1SearchSharedResponse"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "query",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"MusicBackend"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/search/artists": {
|
||||
"get": {
|
||||
"operationId": "MusicBackend_SearchArtists",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1SearchArtistResponse"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "query",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"MusicBackend"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/search/collections": {
|
||||
"get": {
|
||||
"operationId": "MusicBackend_SearchCollections",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1SearchCollectionResponse"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "query",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"MusicBackend"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/song/{hash}": {
|
||||
"get": {
|
||||
"operationId": "MusicBackend_Song",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1SongResponse"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "hash",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"MusicBackend"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/ping": {
|
||||
"get": {
|
||||
"operationId": "MusicBackend_Ping",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A successful response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1PingResponse"
|
||||
}
|
||||
},
|
||||
"default": {
|
||||
"description": "An unexpected error response.",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/rpcStatus"
|
||||
}
|
||||
}
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ping",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"MusicBackend"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"apiv1Artist": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"artist": {
|
||||
"type": "string"
|
||||
},
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"apiv1Song": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"beatmapId": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"md5Hash": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"artist": {
|
||||
"type": "string"
|
||||
},
|
||||
"creator": {
|
||||
"type": "string"
|
||||
},
|
||||
"folder": {
|
||||
"type": "string"
|
||||
},
|
||||
"file": {
|
||||
"type": "string"
|
||||
},
|
||||
"audio": {
|
||||
"type": "string"
|
||||
},
|
||||
"totalTime": {
|
||||
"type": "string",
|
||||
"format": "int64"
|
||||
},
|
||||
"image": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"title": "===== models ====="
|
||||
},
|
||||
"protobufAny": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"@type": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"rpcStatus": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"details": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/protobufAny"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1ArtistResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"songs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/apiv1Song"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1CollectionPreview": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"image": {
|
||||
"type": "string"
|
||||
},
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1CollectionResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
},
|
||||
"songs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/apiv1Song"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1FavoriteResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"songs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/apiv1Song"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1PingResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pong": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1RecentResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"songs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/apiv1Song"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1SearchArtistResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"Artists": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/apiv1Artist"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1SearchCollectionResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"collections": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/v1CollectionPreview"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1SearchSharedResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"artist": {
|
||||
"type": "string"
|
||||
},
|
||||
"songs": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"$ref": "#/definitions/apiv1Song"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1SongResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"song": {
|
||||
"$ref": "#/definitions/apiv1Song"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
frontend/openapitools.json
Normal file
7
frontend/openapitools.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
|
||||
"spaces": 2,
|
||||
"generator-cli": {
|
||||
"version": "7.13.0"
|
||||
}
|
||||
}
|
||||
2222
frontend/package-lock.json
generated
2222
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,14 +8,17 @@
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build"
|
||||
"type-check": "vue-tsc --build",
|
||||
"generate": "openapi-generator-cli generate -i api-specs/osu_music.swagger.json -g typescript-axios -o ./src/generated"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
"pinia": "^2.2.6",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openapitools/openapi-generator-cli": "^2.20.2",
|
||||
"@tsconfig/node22": "^22.0.0",
|
||||
"@types/node": "^22.9.3",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
|
||||
@@ -6,15 +6,12 @@ import MenuView from '@/views/MenuView.vue'
|
||||
import Footer from '@/components/Footer.vue'
|
||||
import { ref, onMounted, watch, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useHeaderStore } from '@/stores/headerStore';
|
||||
import { isMobile, isPc } from './script/utils.ts'
|
||||
import { useAudio } from './composables/useAudio.ts'
|
||||
|
||||
const headerStore = useHeaderStore();
|
||||
|
||||
const showFooter = ref(true);
|
||||
const showNowPlaying = ref(true);
|
||||
const route = useRoute();
|
||||
const audio = useAudio();
|
||||
|
||||
watch(route, async (to) => {
|
||||
|
||||
@@ -23,13 +20,13 @@ watch(route, async (to) => {
|
||||
} else {
|
||||
showNowPlaying.value = true;
|
||||
}
|
||||
|
||||
/*
|
||||
if (route.path.startsWith("/menu")) {
|
||||
headerStore.hide();
|
||||
} else {
|
||||
headerStore.show();
|
||||
}
|
||||
|
||||
*/
|
||||
})
|
||||
|
||||
function loadColors() {
|
||||
@@ -57,22 +54,17 @@ const checkScreenSize = () => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
checkScreenSize(); // Initial check
|
||||
|
||||
|
||||
checkScreenSize();
|
||||
window.addEventListener('resize', checkScreenSize);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// Clean up the listener when component is destroyed
|
||||
window.removeEventListener('resize', checkScreenSize);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
|
||||
<div v-if="screenInfo.isSmall" class="flex flex-col h-screen max-h-screen wrapper info text-xl ">
|
||||
|
||||
<RouterView />
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { Song, CollectionPreview } from '../script/types'
|
||||
import { useAudioStore } from '@/stores/audioStore';
|
||||
import { useAudio } from '@/composables/useAudio';
|
||||
import { ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
|
||||
const audioStore = useAudioStore()
|
||||
const audioStore = useAudio()
|
||||
|
||||
const props = defineProps<{
|
||||
songs: Song[];
|
||||
|
||||
@@ -1,52 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import SongItem from '../components/SongItem.vue'
|
||||
import type { Song, CollectionPreview } from '../script/types'
|
||||
import type { Song } from '../script/types'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useRoute } from 'vue-router'
|
||||
import CollectionListItem from '../components/CollectionListItem.vue'
|
||||
import { useAudioStore } from '@/stores/audioStore';
|
||||
import { useAudio } from '@/composables/useAudio'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
const audioStore = useAudioStore();
|
||||
const route = useRoute()
|
||||
const audioStore = useAudio()
|
||||
const { musicApi } = useApi()
|
||||
|
||||
const songs = ref<Song[]>([]);
|
||||
const name = ref('name');
|
||||
const songs = ref<Song[]>([])
|
||||
const name = ref('name')
|
||||
|
||||
onMounted(async () => {
|
||||
const data = await userStore.fetchCollection(Number(route.params.id));
|
||||
console.log(data)
|
||||
try {
|
||||
const response = await musicApi().musicBackendCollections(Array.isArray(route.params.id) ? route.params.id[0] : route.params.id) // Adjust method name if needed
|
||||
|
||||
data.songs.forEach(song => {
|
||||
song.previewimage = `${userStore.baseUrl}/api/v1/images/${song.previewimage}`;
|
||||
song.url = `${userStore.baseUrl}/api/v1/audio/${song.url}`;
|
||||
});
|
||||
const base = import.meta.env.VITE_MUSIC_API_URL || 'http://localhost:8080'
|
||||
|
||||
name.value = data.name;
|
||||
songs.value = data.songs;
|
||||
response.songs.forEach(song => {
|
||||
song.previewimage = `${base}/api/v1/images/${song.previewimage}`
|
||||
song.url = `${base}/api/v1/audio/${song.url}`
|
||||
})
|
||||
|
||||
audioStore.setCollection(songs.value)
|
||||
});
|
||||
name.value = response.name
|
||||
songs.value = response.songs
|
||||
|
||||
audioStore.setCollection(songs.value)
|
||||
} catch (error) {
|
||||
console.error('Error fetching collection:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<div class="wrapper">
|
||||
<nav class="flex justify-start my-2 mx-1 space-x-1">
|
||||
<RouterLink class="p-1 rounded-full backdrop--light shadow-xl" to="/menu/collections"><i
|
||||
class="fa-solid fa-arrow-left"></i>
|
||||
</RouterLink>
|
||||
<h1 class="px-8 text-nowrap overflow-scroll absolute left-0 right-0 text-center"> {{ name }} </h1>
|
||||
|
||||
</nav>
|
||||
<hr>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 flex-col h-full overflow-scroll">
|
||||
|
||||
<SongItem v-for="(song, index) in songs" :key="index" :song="song" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { defineProps } from 'vue';
|
||||
import type { CollectionPreview } from '@/script/types';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { useAudioStore } from '@/stores/audioStore';
|
||||
const userStore = useUserStore();
|
||||
const audioStore = useAudioStore();
|
||||
|
||||
const props = defineProps<{ collection: CollectionPreview }>();
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { useUser } from '@/composables/useUser';
|
||||
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const userStore = useUser();
|
||||
|
||||
</script>
|
||||
|
||||
@@ -10,7 +10,7 @@ const userStore = useUserStore();
|
||||
<hr>
|
||||
<nav class="flex justify-around my-2 text-sm md:text-2xl">
|
||||
<RouterLink class="p-1 rounded-full backdrop--light shadow-xl hover:text-pink-500" to="/me">
|
||||
<img :src="userStore.User ? userStore.User.avatar_url : 'https://osu.ppy.sh/images/layout/avatar-guest.png'"
|
||||
<img :src="userStore.user.value?.avatar_url || 'https://osu.ppy.sh/images/layout/avatar-guest.png'"
|
||||
class="md:h-12 h-6 rounded-full">
|
||||
</RouterLink>
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAudioStore } from '@/stores/audioStore';
|
||||
import { useAudio } from '@/composables/useAudio';
|
||||
|
||||
const audioStore = useAudioStore();
|
||||
const audioStore = useAudio();
|
||||
</script>
|
||||
|
||||
|
||||
@@ -10,7 +9,7 @@ const audioStore = useAudioStore();
|
||||
<div>
|
||||
<hr>
|
||||
<div class="relative wrapper p-1 grow action">
|
||||
<img :src="encodeURI(audioStore.bgimg + '?h=150&w=400')" class="w-full absolute top-0 left-0 right-0 h-full"
|
||||
<img :src="encodeURI(audioStore.bgimg.value + '?h=150&w=400')" class="w-full absolute top-0 left-0 right-0 h-full"
|
||||
:style="{ 'filter': 'blur(2px)', 'opacity': '0.5' }" alt="Background Image" />
|
||||
|
||||
<nav class="relative flex-col z-10">
|
||||
@@ -18,27 +17,27 @@ const audioStore = useAudioStore();
|
||||
<div class="flex justify-between">
|
||||
<RouterLink to="/nowplaying" class="grow overflow-hidden">
|
||||
<p class="relative text-sm text-left font-bold info overflow-hidden text-ellipsis text-nowrap">
|
||||
{{ audioStore.title }}
|
||||
{{ audioStore.title.value }}
|
||||
</p>
|
||||
|
||||
<p class="relative text-sm text-left font-bold info text-nowrap">
|
||||
{{ audioStore.artist }}
|
||||
{{ audioStore.artist.value }}
|
||||
</p>
|
||||
</RouterLink>
|
||||
<div class="flex flex-col text-center justify-center px-2" @click="audioStore.togglePlay">
|
||||
<i :class="[audioStore.isPlaying ? ' fa-circle-play' : 'fa-circle-pause']" class="text-3xl fa-regular"></i>
|
||||
<i :class="[audioStore.isPlaying.value ? ' fa-circle-play' : 'fa-circle-pause']" class="text-3xl fa-regular"></i>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="w-full bg-gray-200 rounded-full h-0.5 dark:bg-gray-700">
|
||||
<div class="bg-blue-600 h-0.5 rounded-full dark:bg-yellow-500"
|
||||
:style="{ 'width': audioStore.percentDone + '%' }">
|
||||
:style="{ 'width': audioStore.percentDone.value + '%' }">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</nav>
|
||||
<audio controls class="hidden" id="audio-player" :src="audioStore.songSrc"
|
||||
<audio controls class="hidden" id="audio-player" :src="audioStore.songSrc.value"
|
||||
@timeupdate="audioStore.update"></audio>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, defineProps } from 'vue'
|
||||
import { useAudioStore } from '@/stores/audioStore';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { useAudio } from '@/composables/useAudio';
|
||||
import type { Song } from '@/script/types';
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -10,8 +8,7 @@ const props = defineProps<{
|
||||
info?: string,
|
||||
border?: string,
|
||||
}>();
|
||||
const userStore = useUserStore();
|
||||
const audioStore = useAudioStore();
|
||||
const audioStore = useAudio();
|
||||
|
||||
function updateSong() {
|
||||
|
||||
|
||||
19
frontend/src/composables/useApi.ts
Normal file
19
frontend/src/composables/useApi.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { MusicBackendApi, Configuration, } from '@/generated';
|
||||
import type { ConfigurationParameters } from '@/generated';
|
||||
import { ref } from 'vue';
|
||||
|
||||
export function useApi() {
|
||||
const basePath = ref(import.meta.env.BACKEND_URL || 'http://localhost:8080');
|
||||
const musicApi = (): MusicBackendApi => {
|
||||
const configParams: ConfigurationParameters = {
|
||||
basePath: basePath.value,
|
||||
};
|
||||
|
||||
const configuration = new Configuration(configParams);
|
||||
return new MusicBackendApi(configuration);
|
||||
};
|
||||
|
||||
return {
|
||||
musicApi,
|
||||
};
|
||||
}
|
||||
229
frontend/src/composables/useAudio.ts
Normal file
229
frontend/src/composables/useAudio.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { mapApiToSongs, type Song } from '@/script/types'
|
||||
import { useApi } from './useApi'
|
||||
|
||||
let audioInstance: ReturnType<typeof createAudio> | null = null
|
||||
|
||||
function createAudio() {
|
||||
const { musicApi } = useApi()
|
||||
|
||||
const songSrc = ref('https://cdn.pixabay.com/audio/2024/05/24/audio_46382ae035.mp3')
|
||||
const artist = ref('Artist')
|
||||
const title = ref('Title')
|
||||
const bgimg = ref('https://assets.ppy.sh/beatmaps/2197744/covers/cover@2x.jpg?1722207959')
|
||||
const hash = ref('0000')
|
||||
|
||||
const isPlaying = ref(false)
|
||||
const duration = ref('0:00')
|
||||
const currentTime = ref('0:00')
|
||||
const percentDone = ref(0)
|
||||
|
||||
const shuffle = ref(false)
|
||||
const repeat = ref(false)
|
||||
|
||||
const activeCollection = ref<Song[]>([])
|
||||
const currentSong = ref<Song | null>(null)
|
||||
|
||||
function saveSongToLocalStorage(song: Song) {
|
||||
console.log(song)
|
||||
localStorage.setItem('lastPlayedSong', JSON.stringify(song))
|
||||
}
|
||||
|
||||
function loadSongFromLocalStorage(): Song | null {
|
||||
const song = localStorage.getItem('lastPlayedSong')
|
||||
return song ? JSON.parse(song) : null
|
||||
}
|
||||
|
||||
function saveCollectionToLocalStorage(collection: Song[]) {
|
||||
localStorage.setItem('lastActiveCollection', JSON.stringify(collection))
|
||||
}
|
||||
|
||||
function loadCollectionFromLocalStorage(): Song[] | null {
|
||||
const collection = localStorage.getItem('lastActiveCollection')
|
||||
return collection ? JSON.parse(collection) : null
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
const audio = document.getElementById('audio-player') as HTMLAudioElement
|
||||
if (!audio) return
|
||||
if (audio.paused) {
|
||||
audio.play()
|
||||
} else {
|
||||
audio.pause()
|
||||
}
|
||||
}
|
||||
|
||||
function update() {
|
||||
const audio = document.getElementById('audio-player') as HTMLAudioElement
|
||||
if (!audio) return
|
||||
|
||||
isPlaying.value = !audio.paused
|
||||
|
||||
const current_min = Math.floor(audio.currentTime / 60)
|
||||
const current_sec = Math.floor(audio.currentTime % 60)
|
||||
if (!isNaN(current_min) && !isNaN(current_sec)) {
|
||||
currentTime.value = `${current_min}:${current_sec.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const duration_min = Math.floor(audio.duration / 60)
|
||||
const duration_sec = Math.floor(audio.duration % 60)
|
||||
if (!isNaN(duration_min) && !isNaN(duration_sec)) {
|
||||
duration.value = `${duration_min}:${duration_sec.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const percent = (audio.currentTime / audio.duration) * 100
|
||||
if (!isNaN(percent)) {
|
||||
percentDone.value = percent
|
||||
}
|
||||
|
||||
if (audio.ended) {
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
function next() {
|
||||
const audio = document.getElementById('audio-player') as HTMLAudioElement
|
||||
if (!audio) return
|
||||
|
||||
if (repeat.value) {
|
||||
audio.pause()
|
||||
audio.currentTime = 0
|
||||
audio.play()
|
||||
return
|
||||
}
|
||||
|
||||
if (shuffle.value) {
|
||||
audio.pause()
|
||||
const randomSong = activeCollection.value[Math.floor(Math.random() * activeCollection.value.length)]
|
||||
setSong(randomSong)
|
||||
audio.play()
|
||||
return
|
||||
}
|
||||
|
||||
toggleNext()
|
||||
}
|
||||
|
||||
function updateTime() {
|
||||
const audio = document.getElementById('audio-player') as HTMLAudioElement
|
||||
const slider = document.getElementById('audio-slider') as HTMLInputElement
|
||||
if (!audio || !slider) return
|
||||
|
||||
audio.currentTime = (Number(slider.value) / 100) * audio.duration
|
||||
}
|
||||
|
||||
function togglePrev() {
|
||||
const index = activeCollection.value.findIndex((s) => s.hash === hash.value)
|
||||
if (index === -1) return
|
||||
const prevIndex = (index - 1 + activeCollection.value.length) % activeCollection.value.length
|
||||
setSong(activeCollection.value[prevIndex])
|
||||
}
|
||||
|
||||
function toggleNext() {
|
||||
let index = 0
|
||||
if (shuffle.value) {
|
||||
index = Math.floor(Math.random() * activeCollection.value.length)
|
||||
} else {
|
||||
index = activeCollection.value.findIndex((s) => s.hash === hash.value)
|
||||
if (index === -1) return
|
||||
index = (index + 1) % activeCollection.value.length
|
||||
}
|
||||
setSong(activeCollection.value[index])
|
||||
}
|
||||
|
||||
function toggleShuffle() {
|
||||
shuffle.value = !shuffle.value
|
||||
}
|
||||
|
||||
function toggleRepeat() {
|
||||
repeat.value = !repeat.value
|
||||
}
|
||||
|
||||
function setSong(song: Song | null) {
|
||||
if (!song) return
|
||||
|
||||
songSrc.value = song.url
|
||||
artist.value = song.artist
|
||||
title.value = song.name
|
||||
bgimg.value = song.previewimage
|
||||
hash.value = song.hash
|
||||
|
||||
currentSong.value = song
|
||||
saveSongToLocalStorage(song)
|
||||
|
||||
const audio = document.getElementById('audio-player') as HTMLAudioElement
|
||||
if (!audio) return
|
||||
if (!audio.paused) audio.pause()
|
||||
audio.src = song.url
|
||||
|
||||
audio.addEventListener('canplaythrough', () => {
|
||||
audio.play().catch(console.error)
|
||||
}, { once: true })
|
||||
}
|
||||
|
||||
function setCollection(songs: Song[] | null) {
|
||||
if (!songs) return
|
||||
activeCollection.value = songs
|
||||
saveCollectionToLocalStorage(songs)
|
||||
}
|
||||
|
||||
async function loadInitialCollection() {
|
||||
try {
|
||||
const api = musicApi()
|
||||
const response = await api.musicBackendRecent()
|
||||
if (response.data.songs) {
|
||||
setCollection(mapApiToSongs(response.data.songs))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load songs:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
setSong(loadSongFromLocalStorage())
|
||||
setCollection(loadCollectionFromLocalStorage())
|
||||
|
||||
console.log(activeCollection.value)
|
||||
console.log(currentSong.value)
|
||||
if (!currentSong.value || activeCollection.value.length === 0) {
|
||||
loadInitialCollection()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init()
|
||||
})
|
||||
|
||||
return {
|
||||
songSrc,
|
||||
artist,
|
||||
title,
|
||||
bgimg,
|
||||
hash,
|
||||
isPlaying,
|
||||
duration,
|
||||
currentTime,
|
||||
percentDone,
|
||||
shuffle,
|
||||
repeat,
|
||||
activeCollection,
|
||||
currentSong,
|
||||
togglePlay,
|
||||
update,
|
||||
next,
|
||||
updateTime,
|
||||
togglePrev,
|
||||
toggleNext,
|
||||
toggleShuffle,
|
||||
toggleRepeat,
|
||||
setSong,
|
||||
setCollection,
|
||||
init,
|
||||
}
|
||||
}
|
||||
|
||||
export function useAudio() {
|
||||
if (!audioInstance) {
|
||||
audioInstance = createAudio()
|
||||
}
|
||||
return audioInstance
|
||||
}
|
||||
45
frontend/src/composables/useColors.ts
Normal file
45
frontend/src/composables/useColors.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { ref } from 'vue';
|
||||
|
||||
export function useThemeColors() {
|
||||
const bgColor = ref(localStorage.getItem('bgColor') || '#1c1719');
|
||||
const actionColor = ref(localStorage.getItem('actionColor') || '#eab308');
|
||||
const infoColor = ref(localStorage.getItem('infoColor') || '#ec4899');
|
||||
const borderColor = ref(localStorage.getItem('borderColor') || '#ec4899');
|
||||
|
||||
function applyColors(bg: string, main: string, info: string, border: string) {
|
||||
document.documentElement.style.setProperty('--background-color', bg);
|
||||
document.documentElement.style.setProperty('--action-color', main);
|
||||
document.documentElement.style.setProperty('--information-color', info);
|
||||
document.documentElement.style.setProperty('--border-color', border);
|
||||
}
|
||||
|
||||
function save(
|
||||
bg: string | null = null,
|
||||
main: string | null = null,
|
||||
info: string | null = null,
|
||||
border: string | null = null
|
||||
) {
|
||||
bgColor.value = bg ?? bgColor.value;
|
||||
actionColor.value = main ?? actionColor.value;
|
||||
infoColor.value = info ?? infoColor.value;
|
||||
borderColor.value = border ?? borderColor.value;
|
||||
|
||||
applyColors(bgColor.value, actionColor.value, infoColor.value, borderColor.value);
|
||||
|
||||
localStorage.setItem('bgColor', bgColor.value);
|
||||
localStorage.setItem('actionColor', actionColor.value);
|
||||
localStorage.setItem('infoColor', infoColor.value);
|
||||
localStorage.setItem('borderColor', borderColor.value);
|
||||
}
|
||||
|
||||
// Initialize colors on composable use
|
||||
applyColors(bgColor.value, actionColor.value, infoColor.value, borderColor.value);
|
||||
|
||||
return {
|
||||
bgColor,
|
||||
actionColor,
|
||||
infoColor,
|
||||
borderColor,
|
||||
save,
|
||||
};
|
||||
}
|
||||
66
frontend/src/composables/useUser.ts
Normal file
66
frontend/src/composables/useUser.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { ref } from 'vue';
|
||||
import type { Me } from '@/script/types';
|
||||
|
||||
let userInstance: ReturnType<typeof createUser> | null = null;
|
||||
|
||||
function createUser() {
|
||||
const user = ref<Me | null>(null);
|
||||
const baseUrl = ref(import.meta.env.VITE_BACKEND_URL || 'http://localhost:8080');
|
||||
const proxyUrl = ref(import.meta.env.VITE_PROXY_URL || 'http://localhost:8081');
|
||||
|
||||
function saveUser(u: Me | null) {
|
||||
localStorage.setItem('activeUser', JSON.stringify(u));
|
||||
}
|
||||
|
||||
function loadUser(): Me | null {
|
||||
const u = localStorage.getItem('activeUser');
|
||||
return u ? JSON.parse(u) : null;
|
||||
}
|
||||
|
||||
function setUser(u: Me | null) {
|
||||
user.value = u;
|
||||
saveUser(u);
|
||||
}
|
||||
|
||||
async function fetchMe(): Promise<Me | {}> {
|
||||
try {
|
||||
const response = await fetch(`${proxyUrl.value}/me`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.redirected) {
|
||||
window.open(response.url, '_blank');
|
||||
return { redirected: true };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`Fetch failed: ${response.status} ${response.statusText}`);
|
||||
return { id: -1 } as Me;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Fetch error:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
setUser(loadUser());
|
||||
|
||||
return {
|
||||
user,
|
||||
baseUrl,
|
||||
proxyUrl,
|
||||
setUser,
|
||||
fetchMe,
|
||||
};
|
||||
}
|
||||
|
||||
export function useUser() {
|
||||
if (!userInstance) {
|
||||
userInstance = createUser();
|
||||
}
|
||||
return userInstance;
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Apiv1Song, v1CollectionPreview } from '@/generated';
|
||||
|
||||
export type Song = {
|
||||
hash: string;
|
||||
name: string;
|
||||
@@ -8,6 +10,20 @@ export type Song = {
|
||||
mapper: string;
|
||||
};
|
||||
|
||||
const basePath = import.meta.env.BACKEND_URL || 'http://localhost:8080';
|
||||
|
||||
export function mapToSong(apiSong: Apiv1Song): Song {
|
||||
return {
|
||||
hash: apiSong.md5Hash,
|
||||
name: apiSong.title,
|
||||
artist: apiSong.artist,
|
||||
length: Number(apiSong.totalTime),
|
||||
url: `${basePath}/api/v1/audio/${btoa(apiSong.folder + "/" + apiSong.audio).replace(/=+$/, '')}`,
|
||||
previewimage: `${basePath}/api/v1/image/${btoa(apiSong.image === "" || apiSong.image === undefined ? "404.png" : apiSong.image).replace(/=+$/, '')}`,
|
||||
mapper: apiSong.creator,
|
||||
};
|
||||
}
|
||||
|
||||
export type CollectionPreview = {
|
||||
index: number;
|
||||
name: string;
|
||||
@@ -15,6 +31,19 @@ export type CollectionPreview = {
|
||||
previewimage: string;
|
||||
};
|
||||
|
||||
|
||||
export function mapToCollectionPreview(
|
||||
apiCollection: v1CollectionPreview,
|
||||
index: number
|
||||
): CollectionPreview {
|
||||
return {
|
||||
index,
|
||||
name: apiCollection.name,
|
||||
length: apiCollection.items,
|
||||
previewimage: `${basePath}/api/v1/images/${apiCollection.image}`,
|
||||
};
|
||||
}
|
||||
|
||||
export type Me = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -22,3 +51,13 @@ export type Me = {
|
||||
endpoint: string;
|
||||
share: boolean;
|
||||
};
|
||||
|
||||
export function mapApiToSongs(apiSongs: Apiv1Song[]): Song[] {
|
||||
return apiSongs.map(mapToSong);
|
||||
}
|
||||
|
||||
export function mapApiToCollectionPreview(
|
||||
apiCollections: v1CollectionPreview[]
|
||||
): CollectionPreview[] {
|
||||
return apiCollections.map((c, i) => mapToCollectionPreview(c, i));
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { Song, CollectionPreview } from '@/script/types';
|
||||
|
||||
export const useAudioStore = defineStore('audioStore', () => {
|
||||
|
||||
const songSrc = ref('https://cdn.pixabay.com/audio/2024/05/24/audio_46382ae035.mp3')
|
||||
|
||||
const artist = ref('Artist ');
|
||||
const title = ref('Title ');
|
||||
const bgimg = ref('https://assets.ppy.sh/beatmaps/2197744/covers/cover@2x.jpg?1722207959');
|
||||
const hash = ref('0000');
|
||||
|
||||
const isPlaying = ref(true)
|
||||
const duration = ref('0:00')
|
||||
const currentTime = ref('0:00')
|
||||
const percentDone = ref(0)
|
||||
|
||||
const shuffle = ref(false);
|
||||
const repeat = ref(false);
|
||||
|
||||
const activeCollection = ref<Song[]>([]);
|
||||
const currentSong = ref<Song | null>(null);
|
||||
|
||||
function saveSongToLocalStorage(song: Song) {
|
||||
localStorage.setItem('lastPlayedSong', JSON.stringify(song));
|
||||
}
|
||||
|
||||
function loadSongFromLocalStorage(): Song | null {
|
||||
const song = localStorage.getItem('lastPlayedSong');
|
||||
return song ? JSON.parse(song) : null;
|
||||
}
|
||||
|
||||
function saveCollectionToLocalStorage(collection: Song[]) {
|
||||
localStorage.setItem('lastActiveCollection', JSON.stringify(collection));
|
||||
}
|
||||
|
||||
function loadCollectionFromLocalStorage(): Song[] | null {
|
||||
const collection = localStorage.getItem('lastActiveCollection');
|
||||
return collection ? JSON.parse(collection) : null;
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
var audio = document.getElementById("audio-player") as HTMLAudioElement;
|
||||
|
||||
if (audio.paused) {
|
||||
audio.play();
|
||||
} else {
|
||||
audio.pause();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function update() {
|
||||
var audio = document.getElementById("audio-player") as HTMLAudioElement;
|
||||
|
||||
isPlaying.value = audio.paused;
|
||||
|
||||
let current_min = Math.floor(audio.currentTime / 60);
|
||||
let current_sec = Math.round(audio.currentTime % 60);
|
||||
|
||||
if (!isNaN(current_sec) && !isNaN(current_min)) {
|
||||
currentTime.value = current_min + ':' + current_sec.toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
let duration_min = Math.floor(audio.duration / 60);
|
||||
let duration_sec = Math.round(audio.duration % 60);
|
||||
|
||||
if (!isNaN(duration_sec) && !isNaN(duration_min)) {
|
||||
duration.value = duration_min + ':' + duration_sec.toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
|
||||
let percent = (audio.currentTime / audio.duration) * 100
|
||||
|
||||
if (!isNaN(percent)) {
|
||||
percentDone.value = percent;
|
||||
}
|
||||
|
||||
if (audio.ended) {
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
function next() {
|
||||
var audio = document.getElementById("audio-player") as HTMLAudioElement;
|
||||
|
||||
if (repeat.value) {
|
||||
audio.pause()
|
||||
audio.currentTime = 0;
|
||||
audio.play()
|
||||
return;
|
||||
}
|
||||
|
||||
if (shuffle.value) {
|
||||
audio.pause()
|
||||
|
||||
setSong(activeCollection.value[Math.floor(activeCollection.value.length * Math.random())])
|
||||
audio.play()
|
||||
return;
|
||||
}
|
||||
|
||||
toggleNext()
|
||||
}
|
||||
|
||||
function updateTime() {
|
||||
|
||||
var audioslider = document.getElementById("audio-slider") as HTMLInputElement;
|
||||
|
||||
var audio = document.getElementById("audio-player") as HTMLAudioElement;
|
||||
|
||||
audio.currentTime = Math.round((Number(audioslider.value) / 100) * audio.duration)
|
||||
}
|
||||
|
||||
function togglePrev() {
|
||||
let index = activeCollection.value.findIndex(s => s.hash == hash.value);
|
||||
setSong(activeCollection.value[Math.abs((index - 1 + activeCollection.value.length) % activeCollection.value.length)])
|
||||
|
||||
console.log(Math.abs((index - 1 + activeCollection.value.length) % activeCollection.value.length))
|
||||
|
||||
}
|
||||
|
||||
function toggleNext() {
|
||||
let index = 0;
|
||||
if (shuffle.value) {
|
||||
index = Math.floor(activeCollection.value.length * Math.random())
|
||||
|
||||
} else {
|
||||
index = activeCollection.value.findIndex(s => s.hash == hash.value);
|
||||
|
||||
}
|
||||
setSong(activeCollection.value[Math.abs((index + 1) % activeCollection.value.length)])
|
||||
}
|
||||
|
||||
function toggleShuffle() {
|
||||
console.log('shuffle', !shuffle.value)
|
||||
shuffle.value = !shuffle.value;
|
||||
}
|
||||
|
||||
function toggleRepeat() {
|
||||
console.log('repeat', !repeat.value)
|
||||
repeat.value = !repeat.value;
|
||||
}
|
||||
|
||||
function setSong(song: Song | null) {
|
||||
|
||||
if (song === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('setSong', song)
|
||||
var audio = document.getElementById("audio-player") as HTMLAudioElement;
|
||||
|
||||
songSrc.value = song.url;
|
||||
artist.value = song.artist;
|
||||
title.value = song.name;
|
||||
bgimg.value = song.previewimage;
|
||||
hash.value = song.hash;
|
||||
|
||||
currentSong.value = song;
|
||||
saveSongToLocalStorage(song);
|
||||
|
||||
if (audio === null) { return; }
|
||||
|
||||
if (!audio.paused) {
|
||||
audio.pause
|
||||
}
|
||||
|
||||
audio.src = song.url;
|
||||
|
||||
|
||||
audio.addEventListener('canplaythrough', () => {
|
||||
audio.play().catch(error => {
|
||||
console.error('Playback error:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setCollection(songs: Song[] | null) {
|
||||
if (songs === null) { return; }
|
||||
activeCollection.value = songs;
|
||||
saveCollectionToLocalStorage(songs);
|
||||
}
|
||||
|
||||
setSong(loadSongFromLocalStorage());
|
||||
setCollection(loadCollectionFromLocalStorage());
|
||||
|
||||
return { setCollection, songSrc, updateTime, artist, title, bgimg, shuffle, repeat, setSong, togglePlay, togglePrev, toggleNext, toggleRepeat, toggleShuffle, isPlaying, currentTime, duration, update, percentDone, currentSong }
|
||||
})
|
||||
@@ -1,8 +1,11 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import type { Song, CollectionPreview, Me } from '@/script/types';
|
||||
import { useApi } from '@/composables/useApi';
|
||||
|
||||
export const useUserStore = defineStore('userStore', () => {
|
||||
const { musicApi } = useApi();
|
||||
|
||||
const userId = ref(null)
|
||||
const baseUrl = ref('https://service.illegalesachen.download')
|
||||
const proxyUrl = ref('https://proxy.illegalesachen.download')
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { Song, CollectionPreview } from '../script/types'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import CollectionListItem from '../components/CollectionListItem.vue'
|
||||
import { useUser } from '@/composables/useUser';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const userStore = useUser();
|
||||
|
||||
const collections = ref<CollectionPreview[]>([]);
|
||||
const limit = ref(10);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import SongItem from '../components/SongItem.vue'
|
||||
import { useAudioStore } from '@/stores/audioStore';
|
||||
import { useAudio } from '@/composables/useAudio';
|
||||
import { useUser } from '@/composables/useUser';
|
||||
import type { Me } from '@/script/types';
|
||||
|
||||
const audioStore = useAudioStore();
|
||||
const userStore = useUserStore();
|
||||
const audioStore = useAudio();
|
||||
const userStore = useUser();
|
||||
|
||||
const bgColor = ref('');
|
||||
const actionColor = ref('');
|
||||
@@ -16,7 +17,7 @@ const loginStatus = ref('Login');
|
||||
|
||||
function update() {
|
||||
var input = document.getElementById("url-input") as HTMLInputElement;
|
||||
userStore.baseUrl = input.value;
|
||||
userStore.baseUrl.value = input.value;
|
||||
|
||||
}
|
||||
|
||||
@@ -50,7 +51,7 @@ async function getMe() {
|
||||
|
||||
console.log("active user: ", data.name)
|
||||
userStore.setUser(data);
|
||||
userStore.setBaseUrl(data.endpoint);
|
||||
userStore.baseUrl.value(data.endpoint);
|
||||
|
||||
}
|
||||
|
||||
@@ -87,15 +88,15 @@ function reset() {
|
||||
|
||||
<main class="flex-1 flex flex-col overflow-scroll">
|
||||
<h1> Meeeeee </h1>
|
||||
<input @change="update" type="text" id="url-input" :value="userStore.baseUrl" disabled />
|
||||
<input @change="update" type="text" id="url-input" :value="userStore.baseUrl.value" disabled />
|
||||
<br>
|
||||
<button v-if="!userStore.User" @click="getMe" class="border bordercolor rounded-lg p-0.5">{{ loginStatus }}</button>
|
||||
<div v-if="userStore.User" class="flex p-5 justify-between">
|
||||
<img :src="userStore.User.avatar_url" class="w-1/3">
|
||||
<button v-if="!userStore.user.value" @click="getMe" class="border bordercolor rounded-lg p-0.5">{{ loginStatus }}</button>
|
||||
<div v-if="userStore.user.value" class="flex p-5 justify-between">
|
||||
<img :src="userStore.user.value.avatar_url" class="w-1/3">
|
||||
<div>
|
||||
<p>{{ userStore.User.name }}</p>
|
||||
<p>{{ userStore.User.endpoint == "" ? 'Not Connected' : 'Connected' }}</p>
|
||||
<p>Sharing: <button @click="share" class="border bordercolor rounded-lg p-0.5">{{ userStore.User.share
|
||||
<p>{{ userStore.user.value.name }}</p>
|
||||
<p>{{ userStore.user.value.endpoint == "" ? 'Not Connected' : 'Connected' }}</p>
|
||||
<p>Sharing: <button @click="share" class="border bordercolor rounded-lg p-0.5">{{ userStore.user.value.share
|
||||
}}</button></p>
|
||||
<button @click="getMe" class="border bordercolor rounded-lg p-0.5"> Refresh
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useHeaderStore } from '@/stores/headerStore';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
@@ -9,7 +7,6 @@ function isActive(path: string) {
|
||||
return route.path === path ? 'bg-blue-500 text-white' : '';
|
||||
};
|
||||
|
||||
const headerStore = useHeaderStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAudioStore } from '@/stores/audioStore';
|
||||
import { useAudio } from '@/composables/useAudio';
|
||||
|
||||
const audioStore = useAudioStore();
|
||||
const audioStore = useAudio();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -25,7 +24,7 @@ const audioStore = useAudioStore();
|
||||
<i class="relative p-36 fa-solid fa-play">
|
||||
|
||||
<img class="absolute top-4 left-0 bottom-0 right-0 bg-center bg-cover rounded-lg"
|
||||
:src="encodeURI(audioStore.bgimg + '?h=320&w=320')" :key="audioStore.bgimg" />
|
||||
:src="encodeURI(audioStore.bgimg.value + '?h=320&w=320')" :key="audioStore.bgimg.value" />
|
||||
</i>
|
||||
</div>
|
||||
|
||||
@@ -34,25 +33,25 @@ const audioStore = useAudioStore();
|
||||
<div>
|
||||
<div class="flex w-full justify-around">
|
||||
<i class="fa-solid fa-backward-step text-5xl self-center" @click="audioStore.togglePrev"></i>
|
||||
<i :class="[audioStore.isPlaying ? 'fa-circle-play' : 'fa-circle-pause']" class="fa-regular text-7xl "
|
||||
<i :class="[audioStore.isPlaying.value ? 'fa-circle-play' : 'fa-circle-pause']" class="fa-regular text-7xl "
|
||||
@click="audioStore.togglePlay"></i>
|
||||
<i class="fa-solid fa-forward-step text-5xl self-center" @click="audioStore.toggleNext"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-1 justify-around ml-4">
|
||||
<i @click="audioStore.toggleShuffle" :class="[audioStore.shuffle ? 'info' : '']"
|
||||
<i @click="audioStore.toggleShuffle" :class="[audioStore.shuffle.value ? 'info' : '']"
|
||||
class="fa-solid fa-shuffle"></i>
|
||||
|
||||
<div class="m-4 info flex-1 overflow-hidden">
|
||||
<p>{{ audioStore.title }}</p>
|
||||
<RouterLink :to="'search?a=' + audioStore.artist">
|
||||
<p>{{ audioStore.title.value }}</p>
|
||||
<RouterLink :to="'search?a=' + audioStore.artist.value">
|
||||
|
||||
{{ audioStore.artist }}
|
||||
{{ audioStore.artist.value }}
|
||||
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="flex flex-col justify-between mb-4 mr-4">
|
||||
<i @click="audioStore.toggleRepeat" :class="[audioStore.repeat ? 'info' : '']"
|
||||
<i @click="audioStore.toggleRepeat" :class="[audioStore.repeat.value ? 'info' : '']"
|
||||
class="fa-solid fa-repeat"></i>
|
||||
<i @click="$router.go(-1);" class="fa-solid fa-arrow-down"></i>
|
||||
</div>
|
||||
@@ -60,11 +59,11 @@ const audioStore = useAudioStore();
|
||||
<div class="flex">
|
||||
<input
|
||||
class="appearance-none mx-4 flex-1 bg-yellow-200 bg-opacity-20 accent-yellow-600 rounded-lg outline-none slider "
|
||||
type="range" id="audio-slider" @change="audioStore.updateTime" max="100" :value="audioStore.percentDone">
|
||||
type="range" id="audio-slider" @change="audioStore.updateTime" max="100" :value="audioStore.percentDone.value">
|
||||
</div>
|
||||
<div class="flex justify-between mx-4">
|
||||
<span id="current-time" class="time">{{ audioStore.currentTime }}</span>
|
||||
<span id="duration" class="time ">{{ audioStore.duration }}</span>
|
||||
<span id="current-time" class="time">{{ audioStore.currentTime.value }}</span>
|
||||
<span id="duration" class="time ">{{ audioStore.duration.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import SongItem from '../components/SongItem.vue'
|
||||
|
||||
import type { Song, CollectionPreview } from '../script/types'
|
||||
import { type Song, type CollectionPreview, mapApiToSongs } from '../script/types'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { useRoute } from 'vue-router';
|
||||
import CollectionListItem from '../components/CollectionListItem.vue'
|
||||
import { useAudioStore } from '@/stores/audioStore';
|
||||
import { useAudio } from '@/composables/useAudio';
|
||||
import { useUser } from '@/composables/useUser';
|
||||
import { useApi } from '@/composables/useApi';
|
||||
|
||||
const userStore = useUser();
|
||||
const audioStore = useAudio();
|
||||
const { musicApi } = useApi();
|
||||
const api = musicApi()
|
||||
|
||||
const route = useRoute();
|
||||
const userStore = useUserStore();
|
||||
const audioStore = useAudioStore();
|
||||
|
||||
const songs = ref<Song[]>([]);
|
||||
const name = ref('name');
|
||||
@@ -23,19 +25,23 @@ const fetchRecent = async () => {
|
||||
if (isLoading.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
const data = await userStore.fetchRecent(limit.value, offset.value);
|
||||
|
||||
data.forEach(song => {
|
||||
song.previewimage = `${userStore.baseUrl}/api/v1/images/${song.previewimage}`;
|
||||
song.url = `${userStore.baseUrl}/api/v1/audio/${song.url}`;
|
||||
});
|
||||
try {
|
||||
const response = await api.musicBackendRecent(limit.value, offset.value);
|
||||
if (response.data.songs) {
|
||||
let newSongs = mapApiToSongs(response.data.songs);
|
||||
|
||||
offset.value += limit.value;
|
||||
console.log(data)
|
||||
songs.value = [...songs.value, ...data];
|
||||
songs.value = [...songs.value, ...newSongs];
|
||||
|
||||
console.log(offset.value)
|
||||
|
||||
isLoading.value = false;
|
||||
audioStore.setCollection(songs.value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load songs:', error)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,124 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import type { Song, CollectionPreview } from '../script/types'
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
import { mapApiToSongs, type Song } from '../script/types'
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ActiveSearchList from '../components/ActiveSearchList.vue'
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import SongItem from '../components/SongItem.vue'
|
||||
import { useAudioStore } from '@/stores/audioStore';
|
||||
import { useAudio } from '@/composables/useAudio'
|
||||
import { useUser } from '@/composables/useUser'
|
||||
import { MusicBackendApi } from '@/generated'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const audioStore = useAudioStore();
|
||||
const userStore = useUserStore();
|
||||
const activesongs = ref<Song[]>([]);
|
||||
const songs = ref<Song[]>([]);
|
||||
const artists = ref<string[]>([]);
|
||||
const showSearch = ref(false);
|
||||
const searchTerm = ref('');
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const audioStore = useAudio()
|
||||
const userStore = useUser()
|
||||
const { musicApi } = useApi()
|
||||
const api = musicApi()
|
||||
|
||||
const activesongs = ref<Song[]>([])
|
||||
const songs = ref<Song[]>([])
|
||||
const artists = ref<string[]>([])
|
||||
const showSearch = ref(false)
|
||||
const searchTerm = ref('')
|
||||
|
||||
async function fetchActiveSearch(term: string) {
|
||||
const response = await api.musicBackendSearch(term);
|
||||
|
||||
const songData = mapApiToSongs(response.data.songs)
|
||||
|
||||
songData.forEach((song: Song) => {
|
||||
song.previewimage = `${userStore.baseUrl.value}/api/v1/images/${song.previewimage}`
|
||||
song.url = `${userStore.baseUrl.value}/api/v1/audio/${song.url}`
|
||||
})
|
||||
|
||||
activesongs.value = songData
|
||||
|
||||
if (response.data.artist) artists.value = [response.data.artist]
|
||||
audioStore.setCollection(songData)
|
||||
showSearch.value = true
|
||||
searchTerm.value = term
|
||||
router.replace({ query: { s: term } })
|
||||
}
|
||||
|
||||
async function fetchSearchArtist(artist: string) {
|
||||
const data = await musicApi.MusicBackend_SearchArtists(artist)
|
||||
|
||||
data.forEach((song: Song) => {
|
||||
song.previewimage = `${userStore.baseUrl.value}/api/v1/images/${song.previewimage}`
|
||||
song.url = `${userStore.baseUrl.value}/api/v1/audio/${song.url}`
|
||||
})
|
||||
|
||||
songs.value = data
|
||||
showSearch.value = false
|
||||
}
|
||||
|
||||
async function emptySearch() {
|
||||
activesongs.value = []
|
||||
artists.value = []
|
||||
songs.value = []
|
||||
showSearch.value = false
|
||||
searchTerm.value = ''
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
|
||||
await loadartistifexist();
|
||||
|
||||
const container = document.querySelector('.search') as HTMLInputElement;
|
||||
|
||||
if (container) {
|
||||
container.addEventListener('input', async (event: Event) => {
|
||||
showSearch.value = true;
|
||||
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.value != undefined && target.value != "") {
|
||||
const data = await userStore.fetchActiveSearch(target.value)
|
||||
router.push({ query: { s: target.value } });
|
||||
searchTerm.value = target.value
|
||||
|
||||
data.songs.forEach(song => {
|
||||
song.previewimage = `${userStore.baseUrl}/api/v1/images/${song.previewimage}`;
|
||||
song.url = `${userStore.baseUrl}/api/v1/audio/${song.url}`;
|
||||
});
|
||||
|
||||
activesongs.value = data.songs;
|
||||
audioStore.setCollection(data.songs)
|
||||
artists.value = data.artist;
|
||||
} else {
|
||||
activesongs.value = [];
|
||||
artists.value = [];
|
||||
showSearch.value = false;
|
||||
router.push({ query: { s: target.value } });
|
||||
|
||||
}
|
||||
});
|
||||
container.addEventListener('enter', async (event: Event) => {
|
||||
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.value != undefined && target.value != "") {
|
||||
showSearch.value = false
|
||||
const data = await userStore.fetchActiveSearch(target.value)
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
if (route.query.a) {
|
||||
await fetchSearchArtist(route.query.a as string)
|
||||
}
|
||||
const s = route.query.s as string;
|
||||
if (s) { container.value = s; container.dispatchEvent(new Event('input')) }
|
||||
});
|
||||
|
||||
async function loadartistifexist() {
|
||||
const query = route.query.a as string;
|
||||
if (query) {
|
||||
showSearch.value = false;
|
||||
|
||||
const data = await userStore.fetchSearchArtist(query)
|
||||
console.log(data);
|
||||
|
||||
data.forEach(song => {
|
||||
song.previewimage = `${userStore.baseUrl}/api/v1/images/${song.previewimage}`;
|
||||
song.url = `${userStore.baseUrl}/api/v1/audio/${song.url}`;
|
||||
});
|
||||
|
||||
songs.value = data;
|
||||
if (route.query.s) {
|
||||
await fetchActiveSearch(route.query.s as string)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function emptySearch() {
|
||||
const container = document.querySelector('.search') as HTMLInputElement;
|
||||
// Watch for artist query changes
|
||||
watch(() => route.query.a, async (newArtist) => {
|
||||
if (newArtist) {
|
||||
await fetchSearchArtist(newArtist as string)
|
||||
} else {
|
||||
songs.value = []
|
||||
}
|
||||
})
|
||||
|
||||
container.value = "";
|
||||
container.dispatchEvent(new Event('input'))
|
||||
songs.value = [];
|
||||
artists.value = [];
|
||||
|
||||
}
|
||||
watch(() => route.query.a, async (newQuery) => {
|
||||
|
||||
await loadartistifexist();
|
||||
|
||||
});
|
||||
// Search input model
|
||||
const searchInput = ref(searchTerm.value)
|
||||
|
||||
watch(searchInput, async (val) => {
|
||||
if (val && val.trim() !== '') {
|
||||
await fetchActiveSearch(val)
|
||||
} else {
|
||||
showSearch.value = false
|
||||
activesongs.value = []
|
||||
artists.value = []
|
||||
router.replace({ query: {} })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header>
|
||||
<div class="wrapper">
|
||||
<nav class="flex justify-start my-2 mx-1 space-x-1">
|
||||
<RouterLink class="p-1 rounded-full backdrop--light shadow-xl" to="/"><i class="fa-solid fa-arrow-left"></i>
|
||||
<RouterLink class="p-1 rounded-full backdrop--light shadow-xl" to="/">
|
||||
<i class="fa-solid fa-arrow-left"></i>
|
||||
</RouterLink>
|
||||
<h1 class="absolute left-0 right-0 text-center"> Search </h1>
|
||||
<h1 class="absolute left-0 right-0 text-center">Search</h1>
|
||||
</nav>
|
||||
<hr>
|
||||
<hr />
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex flex-col flex-1 flex-col w-full h-full overflow-scroll">
|
||||
<input placeholder="Type to Search..."
|
||||
class="flex-1 max-h-12 search border bordercolor accent-pink-800 bg-yellow-300 bg-opacity-20 rounded-lg m-2 p-2" />
|
||||
|
||||
<div class="absolute h-16 right-4 flex flex-col justify-center">
|
||||
<i @click="emptySearch" class="far fa-times-circle opacity-50"></i>
|
||||
<main class="flex flex-col flex-1 w-full h-full overflow-scroll">
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="searchInput"
|
||||
placeholder="Type to Search..."
|
||||
class="flex-1 max-h-12 search border bordercolor accent-pink-800 bg-yellow-300 bg-opacity-20 rounded-lg m-2 p-2"
|
||||
/>
|
||||
<div class="absolute h-16 right-4 flex flex-col justify-center cursor-pointer" @click="emptySearch">
|
||||
<i class="far fa-times-circle opacity-50"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative flex flex-col w-full h-full overflow-scroll">
|
||||
<div v-if="showSearch" class="absolute w-full text-center search-recommendations z-20">
|
||||
<div v-if="showSearch" class="absolute w-full text-center search-recommendations z-20">
|
||||
<ActiveSearchList :songs="activesongs" :artist="artists" :search="searchTerm" />
|
||||
</div>
|
||||
<SongItem v-for="(song, index) in songs" :key="index" :song="song" />
|
||||
|
||||
Reference in New Issue
Block a user