Compare commits

..

2 Commits

Author SHA1 Message Date
e3ce476788 Version 1.0.0 - Modif changelog 2025-08-29 00:22:29 +02:00
01b089f1f6 Version 1.0.0 - Frontend 2025-08-29 00:22:08 +02:00
83 changed files with 5614 additions and 245 deletions

78
package-lock.json generated
View File

@@ -15,12 +15,13 @@
"@fortawesome/vue-fontawesome": "^3.0.8", "@fortawesome/vue-fontawesome": "^3.0.8",
"@unhead/vue": "^2.0.12", "@unhead/vue": "^2.0.12",
"@vitejs/plugin-vue": "^6.0.0", "@vitejs/plugin-vue": "^6.0.0",
"core-js": "^3.8.3", "@vueuse/core": "^13.7.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"vite": "^7.0.5", "vite": "^7.0.5",
"vue": "^3.2.13", "vue": "^3.2.13",
"vue-router": "^4.0.3" "vue-router": "^4.0.3",
"vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"vite-plugin-vue-devtools": "^7.7.7" "vite-plugin-vue-devtools": "^7.7.7"
@@ -1385,6 +1386,12 @@
"undici-types": "~7.8.0" "undici-types": "~7.8.0"
} }
}, },
"node_modules/@types/web-bluetooth": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
"node_modules/@unhead/vue": { "node_modules/@unhead/vue": {
"version": "2.0.12", "version": "2.0.12",
"resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.0.12.tgz", "resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.0.12.tgz",
@@ -1637,6 +1644,44 @@
"integrity": "sha512-CabR+UN630VnsJO/jHWYBC1YVXyMq94KKp6iF5MQgZJs5I8cmjw6oVMO1oDbtBkENSHSSn/UadWlW/OAgdmKrg==", "integrity": "sha512-CabR+UN630VnsJO/jHWYBC1YVXyMq94KKp6iF5MQgZJs5I8cmjw6oVMO1oDbtBkENSHSSn/UadWlW/OAgdmKrg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vueuse/core": {
"version": "13.7.0",
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.7.0.tgz",
"integrity": "sha512-myagn09+c6BmS6yHc1gTwwsdZilAovHslMjyykmZH3JNyzI5HoWhv114IIdytXiPipdHJ2gDUx0PB93jRduJYg==",
"license": "MIT",
"dependencies": {
"@types/web-bluetooth": "^0.0.21",
"@vueuse/metadata": "13.7.0",
"@vueuse/shared": "13.7.0"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/@vueuse/metadata": {
"version": "13.7.0",
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.7.0.tgz",
"integrity": "sha512-8okFhS/1ite8EwUdZZfvTYowNTfXmVCOrBFlA31O0HD8HKXhY+WtTRyF0LwbpJfoFPc+s9anNJIXMVrvP7UTZg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/@vueuse/shared": {
"version": "13.7.0",
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.7.0.tgz",
"integrity": "sha512-Wi2LpJi4UA9kM0OZ0FCZslACp92HlVNw1KPaDY6RAzvQ+J1s7seOtcOpmkfbD5aBSmMn9NvOakc8ZxMxmDXTIg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -1776,17 +1821,6 @@
"url": "https://github.com/sponsors/mesqueeb" "url": "https://github.com/sponsors/mesqueeb"
} }
}, },
"node_modules/core-js": {
"version": "3.44.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.44.0.tgz",
"integrity": "sha512-aFCtd4l6GvAXwVEh3XbbVqJGHDJt0OZRa+5ePGx3LLwi12WfexqQxcsohb2wgsa/92xtl19Hd66G/L+TaAxDMw==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2994,6 +3028,12 @@
"node": ">=10.0.0" "node": ">=10.0.0"
} }
}, },
"node_modules/sortablejs": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
"license": "MIT"
},
"node_modules/source-map": { "node_modules/source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -3472,6 +3512,18 @@
"vue": "^3.2.0" "vue": "^3.2.0"
} }
}, },
"node_modules/vuedraggable": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
"license": "MIT",
"dependencies": {
"sortablejs": "1.14.0"
},
"peerDependencies": {
"vue": "^3.0.1"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -15,12 +15,13 @@
"@fortawesome/vue-fontawesome": "^3.0.8", "@fortawesome/vue-fontawesome": "^3.0.8",
"@unhead/vue": "^2.0.12", "@unhead/vue": "^2.0.12",
"@vitejs/plugin-vue": "^6.0.0", "@vitejs/plugin-vue": "^6.0.0",
"core-js": "^3.8.3", "@vueuse/core": "^13.7.0",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"vite": "^7.0.5", "vite": "^7.0.5",
"vue": "^3.2.13", "vue": "^3.2.13",
"vue-router": "^4.0.3" "vue-router": "^4.0.3",
"vuedraggable": "^4.1.0"
}, },
"devDependencies": { "devDependencies": {
"vite-plugin-vue-devtools": "^7.7.7" "vite-plugin-vue-devtools": "^7.7.7"

View File

@@ -5,7 +5,7 @@
}, },
"backend": { "backend": {
"development": "http://192.168.1.77:3000", "development": "http://192.168.1.77:3000",
"production": "http://backend.subsonics.raphix.fr" "production": "http://alpha.raphix.fr:3000"
}, },
"bot_invite": { "bot_invite": {
"development": "https://discord.com/api/oauth2/authorize?client_id=1342913183744004158&permissions=40546675842624&scope=bot%20applications.commands", "development": "https://discord.com/api/oauth2/authorize?client_id=1342913183744004158&permissions=40546675842624&scope=bot%20applications.commands",

View File

@@ -4,5 +4,5 @@
<script setup> <script setup>
import { useGlobalStore } from '@/stores/globalStore'; import { useGlobalStore } from '@/stores/globalStore';
const globalStore = useGlobalStore(); const globalStore = useGlobalStore();
console.log("Subsonics Chopin - App Vue Loaded"); console.log("Subsonics Chopin - Welcome");
</script> </script>

View File

@@ -7,14 +7,18 @@
--primary: #111210; --primary: #111210;
--primary-inverse: #FFFFFF;
--primary-hover: #ececec; --primary-hover: #ececec;
--secondary: #2A2B28; --secondary: #2A2B28;
--tertiary: #404040; --tertiary: #404040;
--quaternary: #636363;
--text: #FFFFFF; --text: #FFFFFF;
--text-inverse: #111210; --text-inverse: #111210;
--text-secondary: #C5c3c3; --text-secondary: #C5c3c3;
--text-tertiary: #A5A5A5; --text-tertiary: #A5A5A5;
--text-error: #ff2b2b; --text-error: #ff2b2b;
--text-success: #00ff00;
--text-warning: #FFA500;
} }
@@ -22,22 +26,24 @@
[data-theme='light'] { [data-theme='light'] {
--primary: #FFFFFF; --primary: #FFFFFF;
--primary-inverse: #111210;
--primary-hover: #292b26; --primary-hover: #292b26;
--secondary: #EAEAEA; --secondary: #EAEAEA;
--tertiary: #d3d3d3; --tertiary: #d3d3d3;
--quaternary: #b8b8b8;
--text: #111210; --text: #111210;
--text-inverse: #FFFFFF; --text-inverse: #FFFFFF;
--text-secondary: #404040; --text-secondary: #404040;
--text-tertiary: #C5c3c3; --text-tertiary: #C5c3c3;
--text-error: #CD034F; --text-error: #CD034F;
--text-success: #00a900;
--text-warning: #FFA500;
} }
:root { :root {
--main: #CD034F; --main: #CD034F;
--main-hover: #A0023F; --main-hover: color-mix(in srgb, var(--main) 90%, black);
--main-active: #7A002F; --main-active: color-mix(in srgb, var(--main) 70%, black);
--text-success: #00ff00;
--admin-color: #209AFE; --admin-color: #209AFE;
--owner-color: #FFAA32; --owner-color: #FFAA32;
@@ -87,6 +93,7 @@ a {
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 5px; width: 5px;
margin-right: 20px; margin-right: 20px;
margin-left: 20px;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
border-radius: 12px; border-radius: 12px;
@@ -143,6 +150,7 @@ textarea:focus {
} }
} }
input[type="checkbox"] { input[type="checkbox"] {
background-color: #FFFFFF !important; background-color: #FFFFFF !important;
} }
@@ -154,3 +162,23 @@ input[type="checkbox"]:checked {
input[type="checkbox"]:hover { input[type="checkbox"]:hover {
background-color: var(--main-active) !important; background-color: var(--main-active) !important;
} }
input[type="text"] {
background-color: var(--tertiary);
border: none;
border-radius: 5px;
padding: 7px;
color: var(--text);
font-family: 'Inter', sans-serif;
outline: none;
}
@keyframes scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(var(--scroll-distance));
}
}

View File

@@ -0,0 +1,30 @@
<template>
<svg
width="1.2em"
height="1.2em"
:class="{'iconAction': iconAction}"
viewBox="0 0 512 512"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M40 48C26.7 48 16 58.7 16 72V120C16 133.3 26.7 144 40 144H88C101.3 144 112 133.3 112 120V72C112 58.7 101.3 48 88 48H40ZM192 64C174.3 64 160 78.3 160 96C160 113.7 174.3 128 192 128H480C497.7 128 512 113.7 512 96C512 78.3 497.7 64 480 64H192ZM192 224C174.3 224 160 238.3 160 256C160 273.7 174.3 288 192 288H480C497.7 288 512 273.7 512 256C512 238.3 497.7 224 480 224H192ZM192 384C174.3 384 160 398.3 160 416C160 433.7 174.3 448 192 448H263.5C281.2 448 295.5 433.7 295.5 416C295.5 398.3 281.2 384 263.5 384H192ZM16 232V280C16 293.3 26.7 304 40 304H88C101.3 304 112 293.3 112 280V232C112 218.7 101.3 208 88 208H40C26.7 208 16 218.7 16 232ZM40 368C26.7 368 16 378.7 16 392V440C16 453.3 26.7 464 40 464H88C101.3 464 112 453.3 112 440V392C112 378.7 101.3 368 88 368H40Z" fill="currentColor"/>
<path d="M419.266 488C419.266 431.354 419.266 397.607 419.266 350M350 418H419.266H488" stroke="currentColor" stroke-width="48" stroke-linecap="round"/>
</svg>
</template>
<script setup>
defineProps({
iconAction: Boolean
})
</script>
<style scoped>
svg {
transition: all 0.3s ease-in-out;
cursor: pointer;
}
.iconAction:hover {
opacity: 0.8;
}
</style>

View File

@@ -0,0 +1,6 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="24" viewBox="0 0 28 24" fill="var(--main)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.4615 19.7691C22.2298 20.2687 23.3333 19.799 23.3333 18.9724L23.3333 5.02764C23.3333 4.20106 22.2298 3.73137 21.4615 4.23095L10.7389 11.2033C10.1235 11.6035 10.1235 12.3965 10.7389 12.7967L21.4615 19.7691ZM25.6667 18.9724C25.6667 21.4521 22.3562 22.8612 20.0513 21.3625L9.32864 14.3901C7.48245 13.1896 7.48245 10.8104 9.32865 9.60994L20.0513 2.63757C22.3562 1.13885 25.6667 2.5479 25.6667 5.02764L25.6667 18.9724Z"/>
<path d="M2.33333 3C2.33333 2.44772 2.85566 2 3.49999 2C4.14433 2 4.66666 2.44772 4.66666 3V21C4.66666 21.5523 4.14433 22 3.49999 22C2.85566 22 2.33333 21.5523 2.33333 21V3Z"/>
</svg>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M5.47542 12.8632C4.44449 11.2623 3.67643 9.54134 3.17124 7.76144C3.01103 7.19699 2.93093 6.91477 2.9297 6.50182C2.92833 6.0436 3.08969 5.42311 3.31412 5.0236C3.51636 4.66357 3.78117 4.39876 4.3108 3.86913L4.46843 3.7115C4.99987 3.18006 5.2656 2.91433 5.55098 2.76999C6.11854 2.48292 6.7888 2.48292 7.35636 2.76999C7.64174 2.91433 7.90747 3.18006 8.43891 3.7115L8.63378 3.90637C8.98338 4.25597 9.15819 4.43078 9.27247 4.60656C9.70347 5.26945 9.70347 6.12403 9.27247 6.78692C9.15819 6.9627 8.98338 7.1375 8.63378 7.4871C8.51947 7.60142 8.46231 7.65857 8.41447 7.72539C8.24446 7.96281 8.18576 8.30707 8.26748 8.58743C8.29048 8.66632 8.32041 8.72867 8.38028 8.85337C8.50111 9.10503 8.62956 9.35395 8.76563 9.59979M11.1817 12.8181L11.2266 12.8632C12.4282 14.0648 13.7869 15.0136 15.2365 15.7096C15.3612 15.7694 15.4235 15.7994 15.5024 15.8224C15.7828 15.9041 16.127 15.8454 16.3644 15.6754C16.4313 15.6275 16.4884 15.5704 16.6027 15.4561C16.9523 15.1064 17.1271 14.9316 17.3029 14.8174C17.9658 14.3864 18.8204 14.3864 19.4833 14.8174C19.6591 14.9316 19.8339 15.1064 20.1835 15.4561L20.3783 15.6509C20.9098 16.1824 21.1755 16.4481 21.3198 16.7335C21.6069 17.301 21.6069 17.9713 21.3198 18.5389C21.1755 18.8242 20.9098 19.09 20.3783 19.6214L20.2207 19.779C19.6911 20.3087 19.4263 20.5735 19.0662 20.7757C18.6667 21.0001 18.0462 21.1615 17.588 21.1601C17.1751 21.1589 16.8928 21.0788 16.3284 20.9186C13.295 20.0576 10.4326 18.4332 8.04466 16.0452L7.99976 16.0001M20.9996 3.00012L2.99961 21.0001" stroke="var(--text)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</template>

View File

@@ -0,0 +1,6 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="var(--main)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.60439 4.23093C4.94586 3.73136 4 4.20105 4 5.02762V18.9724C4 19.799 4.94586 20.2686 5.60439 19.7691L14.7952 12.7967C15.3227 12.3965 15.3227 11.6035 14.7952 11.2033L5.60439 4.23093ZM2 5.02762C2 2.54789 4.83758 1.13883 6.81316 2.63755L16.004 9.60993C17.5865 10.8104 17.5865 13.1896 16.004 14.3901L6.81316 21.3625C4.83758 22.8612 2 21.4521 2 18.9724V5.02762Z"/>
<path d="M20 3C20 2.44772 20.4477 2 21 2C21.5523 2 22 2.44772 22 3V21C22 21.5523 21.5523 22 21 22C20.4477 22 20 21.5523 20 21V3Z"/>
</svg>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M6.9 11.4444V14.2222M6.9 11.4444V4.77778C6.9 3.8573 7.66112 3.11111 8.6 3.11111C9.53888 3.11111 10.3 3.8573 10.3 4.77778M6.9 11.4444C6.9 10.524 6.13888 9.77778 5.2 9.77778C4.26112 9.77778 3.5 10.524 3.5 11.4444V13.6667C3.5 18.269 7.30558 22 12 22C16.6944 22 20.5 18.269 20.5 13.6667V8.11111C20.5 7.19064 19.7389 6.44444 18.8 6.44444C17.8611 6.44444 17.1 7.19064 17.1 8.11111M10.3 4.77778V10.8889M10.3 4.77778V3.66667C10.3 2.74619 11.0611 2 12 2C12.9389 2 13.7 2.74619 13.7 3.66667V4.77778M17.1 8.11111V4.77778C17.1 3.8573 16.3389 3.11111 15.4 3.11111C14.4611 3.11111 13.7 3.8573 13.7 4.77778M17.1 8.11111V10.8889M13.7 4.77778V10.8889" stroke="var(--text)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M12 20.5001C16.6944 20.5001 20.5 16.6945 20.5 12.0001C20.5 9.17456 19.1213 6.67103 17 5.1255M13 22.4001L11 20.4001L13 18.4001M12 3.5001C7.30558 3.5001 3.5 7.30568 3.5 12.0001C3.5 14.8256 4.87867 17.3292 7 18.8747M11 5.6001L13 3.6001L11 1.6001" stroke="var(--text)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</template>

View File

@@ -0,0 +1,6 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22" fill="none">
<rect x="1" y="1" width="7" height="19" rx="2" stroke="var(--text)" stroke-width="2"/>
<rect x="13" y="1" width="7" height="19" rx="2" stroke="var(--text)" stroke-width="2"/>
</svg>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 22 22" fill="none">
<path d="M20.5355 10.2262L4.18315 1.51207C3.57861 1.18992 2.85727 1.17287 2.23818 1.46612C1.48207 1.82428 1 2.58605 1 3.4227V18.5773C1 19.4139 1.48207 20.1757 2.23818 20.5339C2.85727 20.8271 3.57861 20.8101 4.18315 20.4879L20.5355 11.7738C20.8214 11.6215 21 11.3239 21 11C21 10.6761 20.8214 10.3785 20.5355 10.2262Z" stroke="var(--text)" stroke-width="2"/>
</svg>
</template>

View File

@@ -0,0 +1,7 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M17.448 2.03365C17.8385 1.64318 18.4716 1.64305 18.8621 2.03343L21.4146 4.58486C22.1959 5.36587 22.1961 6.63242 21.4149 7.41358L18.8675 9.96097C18.477 10.3515 17.8438 10.3515 17.4533 9.96097C17.0628 9.57045 17.0628 8.93728 17.4533 8.54676L19 7.00003H14.2361C13.8573 7.00003 13.511 7.21403 13.3416 7.55282L11.8954 10.4452L10.7699 8.2242L11.5528 6.65839C12.061 5.64204 13.0998 5.00003 14.2361 5.00003H19L17.4479 3.44794C17.0574 3.05741 17.0575 2.42418 17.448 2.03365Z" fill="var(--text)"/>
<path d="M17.448 14.0337C17.8385 13.6432 18.4716 13.643 18.8621 14.0334L21.4146 16.5849C22.1959 17.3659 22.1961 18.6324 21.4149 19.4136L18.8675 21.961C18.477 22.3515 17.8438 22.3515 17.4533 21.961C17.0628 21.5704 17.0628 20.9373 17.4533 20.5468L19 19H14.2361C13.0998 19 12.061 18.358 11.5528 17.3417L6.65836 7.55282C6.48897 7.21403 6.1427 7.00003 5.76393 7.00003H3C2.44772 7.00003 2 6.55232 2 6.00003C2 5.44775 2.44772 5.00003 3 5.00003H5.76393C6.90025 5.00003 7.93904 5.64204 8.44721 6.65839L13.3416 16.4472C13.511 16.786 13.8573 17 14.2361 17H19L17.4479 15.4479C17.0574 15.0574 17.0575 14.4242 17.448 14.0337Z" fill="var(--text)"/>
<path d="M8.12308 13.5178L9.24864 15.7388L8.44721 17.3417C7.93904 18.358 6.90025 19 5.76393 19H3C2.44772 19 2 18.5523 2 18C2 17.4477 2.44772 17 3 17H5.76393C6.1427 17 6.48897 16.786 6.65836 16.4472L8.12308 13.5178Z" fill="var(--text)"/>
</svg>
</template>

View File

@@ -0,0 +1,6 @@
<template>
<svg width="1.2em" height="1.2em" viewBox="0 0 620 501" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M548.255 301.439C531.426 285.682 509.587 277.044 486.418 277.065C444.722 277.101 408.725 305.691 398.787 345.349C398.063 348.236 395.491 350.274 392.515 350.274H361.668C357.632 350.274 354.566 346.61 355.313 342.643C366.96 280.792 421.264 234 486.5 234C522.269 234 554.752 248.069 578.72 270.974L597.946 251.748C606.084 243.609 620 249.373 620 260.884V333.048C620 340.184 614.216 345.968 607.081 345.968H534.916C523.406 345.968 517.642 332.052 525.78 323.913L548.255 301.439ZM365.919 389.032H438.084C449.594 389.032 455.358 402.948 447.22 411.087L424.745 433.562C441.574 449.319 463.415 457.957 486.584 457.936C528.259 457.898 564.27 429.328 574.213 389.652C574.937 386.765 577.509 384.727 580.485 384.727H611.332C615.368 384.727 618.435 388.391 617.688 392.358C606.04 454.208 551.736 501 486.5 501C450.731 501 418.248 486.931 394.28 464.026L375.054 483.252C366.916 491.391 353 485.627 353 474.116V401.952C353 394.816 358.784 389.032 365.919 389.032Z" fill="currentColor"/>
<path d="M534.722 60.083C528.441 36.433 509.935 17.807 486.438 11.486C443.848 0 273.067 0 273.067 0C273.067 0 102.287 0 59.696 11.486C36.199 17.808 17.693 36.433 11.412 60.083C0 102.95 0 192.388 0 192.388C0 192.388 0 281.826 11.412 324.693C17.693 348.343 36.199 366.193 59.696 372.514C102.287 384 273.067 384 273.067 384H323C323 384 311.5 298 386.5 239.5C459.5 182.56 542 211.5 542 211.5C542 211.5 546.134 102.95 534.722 60.083ZM217.212 273.591V111.185L359.951 192.39L217.212 273.591Z" fill="currentColor"/>
</svg>
</template>

View File

@@ -20,9 +20,9 @@
style="stroke-width:1.60707" /> style="stroke-width:1.60707" />
<path <path
d="m 522.62825,760.3253 v 250.8644 h 6.10687 c 3.21417,0 6.91044,-0.6429 8.03538,-1.2856 1.60709,-1.125 1.9285,-36.48067 1.60709,-173.56414 -0.48213,-139.81555 -0.16071,-172.27846 1.60707,-172.27846 8.35679,0 25.23107,14.46368 31.65938,27.48099 8.5175,16.55287 8.83891,21.21339 8.83891,130.33379 0,83.72861 0.32142,100.9243 2.2499,100.9243 1.12496,0 11.41024,-4.66051 22.82047,-10.44597 66.3722,-33.26645 111.3703,-92.0854 123.58408,-161.51106 3.21415,-17.83853 3.69627,-53.67631 0.96424,-69.26493 -5.14264,-30.37371 -19.4456,-64.283 -37.28415,-88.38913 -36.6413,-49.3372 -90.63903,-78.26456 -154.4399,-82.76435 l -15.74934,-1.12496 z" d="m 522.62825,760.3253 v 250.8644 h 6.10687 c 3.21417,0 6.91044,-0.6429 8.03538,-1.2856 1.60709,-1.125 1.9285,-36.48067 1.60709,-173.56414 -0.48213,-139.81555 -0.16071,-172.27846 1.60707,-172.27846 8.35679,0 25.23107,14.46368 31.65938,27.48099 8.5175,16.55287 8.83891,21.21339 8.83891,130.33379 0,83.72861 0.32142,100.9243 2.2499,100.9243 1.12496,0 11.41024,-4.66051 22.82047,-10.44597 66.3722,-33.26645 111.3703,-92.0854 123.58408,-161.51106 3.21415,-17.83853 3.69627,-53.67631 0.96424,-69.26493 -5.14264,-30.37371 -19.4456,-64.283 -37.28415,-88.38913 -36.6413,-49.3372 -90.63903,-78.26456 -154.4399,-82.76435 l -15.74934,-1.12496 z"
fill="#7A258D" fill="var(--main)"
id="path2" id="path2"
style="fill:#cd034f;fill-opacity:1;stroke-width:1.60707" /> style="fill:var(--main);fill-opacity:1;stroke-width:1.60707" />
</g> </g>
</svg> </svg>
</template> </template>

View File

@@ -20,9 +20,9 @@
style="stroke-width:1.60707" /> style="stroke-width:1.60707" />
<path <path
d="m 522.62825,760.3253 v 250.8644 h 6.10687 c 3.21417,0 6.91044,-0.6429 8.03538,-1.2856 1.60709,-1.125 1.9285,-36.48067 1.60709,-173.56414 -0.48213,-139.81555 -0.16071,-172.27846 1.60707,-172.27846 8.35679,0 25.23107,14.46368 31.65938,27.48099 8.5175,16.55287 8.83891,21.21339 8.83891,130.33379 0,83.72861 0.32142,100.9243 2.2499,100.9243 1.12496,0 11.41024,-4.66051 22.82047,-10.44597 66.3722,-33.26645 111.3703,-92.0854 123.58408,-161.51106 3.21415,-17.83853 3.69627,-53.67631 0.96424,-69.26493 -5.14264,-30.37371 -19.4456,-64.283 -37.28415,-88.38913 -36.6413,-49.3372 -90.63903,-78.26456 -154.4399,-82.76435 l -15.74934,-1.12496 z" d="m 522.62825,760.3253 v 250.8644 h 6.10687 c 3.21417,0 6.91044,-0.6429 8.03538,-1.2856 1.60709,-1.125 1.9285,-36.48067 1.60709,-173.56414 -0.48213,-139.81555 -0.16071,-172.27846 1.60707,-172.27846 8.35679,0 25.23107,14.46368 31.65938,27.48099 8.5175,16.55287 8.83891,21.21339 8.83891,130.33379 0,83.72861 0.32142,100.9243 2.2499,100.9243 1.12496,0 11.41024,-4.66051 22.82047,-10.44597 66.3722,-33.26645 111.3703,-92.0854 123.58408,-161.51106 3.21415,-17.83853 3.69627,-53.67631 0.96424,-69.26493 -5.14264,-30.37371 -19.4456,-64.283 -37.28415,-88.38913 -36.6413,-49.3372 -90.63903,-78.26456 -154.4399,-82.76435 l -15.74934,-1.12496 z"
fill="#7A258D" fill="var(--main)"
id="path2" id="path2"
style="fill:#cd034f;fill-opacity:1;stroke-width:1.60707" /> style="fill:var(--main);fill-opacity:1;stroke-width:1.60707" />
</g> </g>
</svg> </svg>
</template> </template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,6 +1,6 @@
<template> <template>
<Box box-class="box" padding="closed"> <Box :box-class="'box'" padding="closed">
<User :user="userStore.userInfo" /> <User :user="userStore.userInfo" />
<div class="user-action"> <div class="user-action">
<IconAction title="Paramètres" icon="fa-solid fa-gear" @click="userSettings.open()"/> <IconAction title="Paramètres" icon="fa-solid fa-gear" @click="userSettings.open()"/>
<IconAction title="Déconnexion" color="red" icon="fa-solid fa-right-from-bracket" @click="signOut(router)"/> <IconAction title="Déconnexion" color="red" icon="fa-solid fa-right-from-bracket" @click="signOut(router)"/>
@@ -31,6 +31,7 @@ const router = useRouter();
const userSettings = ref(null); const userSettings = ref(null);
//FIXME: Set to dispatcher
</script> </script>
<style scoped> <style scoped>
@@ -47,4 +48,5 @@ const userSettings = ref(null);
gap: 15px; gap: 15px;
} }
</style> </style>

View File

@@ -3,12 +3,12 @@
<div class="container"> <div class="container">
<SubsonicsLogo/> <SubsonicsLogo/>
<div ref="serverBox" class="server-box"> <div ref="serverBox" class="server-box">
<Box :overbox="showMenu" level="second" no-shadow padding="closed"> <Box ref="content" :overbox="showMenu" level="second" no-shadow padding="closed">
<div v-if="server" class="itm"> <div v-if="server" class="itm">
<ServerItem :server="server"/> <ServerItem :server="server"/>
<div class="actions"> <div class="actions">
<ListenBox>{{ server.members.length + 1}}</ListenBox> <ListenBox>{{ server.members.length + 1}}</ListenBox>
<IconAction @click="showMenu = !showMenu" :icon="showMenu ? 'fa-solid fa-angle-up' : 'fa-solid fa-angle-down'"/> <IconAction ref="opener" @click="showMenu = !showMenu" :icon="showMenu ? 'fa-solid fa-angle-up' : 'fa-solid fa-angle-down'"/>
</div> </div>
</div> </div>
<Error v-else><ServerItem/></Error> <Error v-else><ServerItem/></Error>
@@ -53,6 +53,7 @@ import { ref, onMounted, onUnmounted } from 'vue';
import GuildHeaderUsers from '../Widget/Guild/GuildHeaderUsers.vue'; import GuildHeaderUsers from '../Widget/Guild/GuildHeaderUsers.vue';
import GuildSettings from '../Widget/Guild/GuildSettings.vue'; import GuildSettings from '../Widget/Guild/GuildSettings.vue';
import { useUserStore } from '@/stores/userStore'; import { useUserStore } from '@/stores/userStore';
import { useGlobalStore } from '@/stores/globalStore';
const userStore = useUserStore(); const userStore = useUserStore();
@@ -60,8 +61,11 @@ const server = ref(undefined)
const showMenu = ref(false); const showMenu = ref(false);
const serverBox = ref(null); const serverBox = ref(null);
const router = useRouter(); const router = useRouter();
const content = ref(null);
const opener = ref(null);
var gestion = ref(false) var gestion = ref(false)
const globalStore = useGlobalStore();
const settings = ref(null); const settings = ref(null);
@@ -74,6 +78,14 @@ onMounted(() => {
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
showMenu.value = false; showMenu.value = false;
}); });
content.value.$el.addEventListener('pointerdown', (ev) => {
if(ev.pointerType === 'mouse') return;
if(opener.value.$el.contains(ev.target)) return;
if(!showMenu.value) {
showMenu.value = true;
}
});
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -88,12 +100,14 @@ onUnmounted(() => {
function updateServerInfo() { function updateServerInfo() {
IORequest("/GUILD/INFO", (data) => { IORequest("/GUILD/INFO", (data) => {
server.value = data; server.value = data;
document.title = data.name + " - Subsonics";
globalStore.actualServer = data;
userStore.userInfo.identity.isAdmin = userStore.userInfo.labels.includes('ADMIN'); userStore.userInfo.identity.isAdmin = userStore.userInfo.labels.includes('ADMIN');
userStore.userInfo.identity.isMod = userStore.userInfo.labels.includes('MOD_' + server.value.id); userStore.userInfo.identity.isMod = userStore.userInfo.labels.includes('MOD_' + server.value.id);
userStore.userInfo.identity.isOwner = userStore.userInfo.identity.id == server.value.owner; userStore.userInfo.identity.isOwner = userStore.userInfo.identity.id == server.value.owner;
gestion.value = userStore.userInfo.labels.includes('ADMIN') || userStore.userInfo.labels.includes('MOD_' + server.value.id) || userStore.userInfo.identity.id == server.value.owner; gestion.value = userStore.userInfo.labels.includes('ADMIN') || userStore.userInfo.labels.includes('MOD_' + server.value.id) || userStore.userInfo.identity.id == server.value.owner;
console.log("Server info updated"); console.log("Server info updated");
events.emit("GUILD_JOINED"); events.emit("GUILD_JOINED", data);
}) })
} }

View File

@@ -0,0 +1,538 @@
<template>
<div class="player-container">
<section class="player-deck">
<Box box-class="player-box" padding="closed">
<div class="player">
<div class="player-line">
<VideoList :player-offline="!online" :video="video" />
<ActualChannel/>
</div>
<DurationBar ref="durationBar" :buffering="buffering" :pause="pause" class="duration-bar" @seek="seek" :offline="!online || !video" :current-time="currentTime" :total-duration="totalDuration" />
<div class="actions">
<div v-if="online" class="actions-div">
<CustomIcon :active="loop" @click="toggleLoop"><Loop active icon="fa-solid fa-sync"/></CustomIcon>
<CustomIcon :active="shuffle" @click="toggleShuffle"><Shuffle icon="fa-solid fa-shuffle"/></CustomIcon>
</div>
<span v-else></span>
<div :class="{'actions-div': true, 'offline': !online || !video}" >
<Backward @click="backward" :class="{'playIcon': online && video, 'itm-sec': true, 'enabled': online}" class="itm-sec" icon="fa-solid fa-backward-step"/>
<div @click="togglePause()" :class="{'playIcon': online && video, 'itm-main': true}">
<Play v-if="pause || !online"/>
<Pause v-else/>
</div>
<Forward @click="forward" :class="{'playIcon': online && video, 'itm-sec': true, 'enabled': online}" class="itm-sec" icon="fa-solid fa-forward-step"/>
</div>
<div v-if="online" class="actions-div">
<CustomIcon @click="changeChannel"><Hand icon="fa-solid fa-hand"/></CustomIcon>
<CustomIcon @click="disconnect"><Disconnect icon="fa-solid fa-phone-slash"/></CustomIcon>
</div>
<span v-else></span>
</div>
</div>
</Box>
</section>
<div class="player-placeholder" :style="{ height: playerHeight + 'px' }"></div>
<div :class="{'player-mobile-container': true, 'player-mobile-container-open': playerOpen}">
<section @click="togglePlayerOpen" ref="playerMobile" v-if="online" :class="{'player-mobile-unfold': playerOpen, 'player-mobile': true}">
<div v-show="playerOpen" class="player-menu">
<DurationBar ref="durationBar" mobile :buffering="buffering" :pause="pause" class="duration-bar" @seek="seek" :offline="!online || !video" :current-time="currentTime" :total-duration="totalDuration" />
<span class="actions-container">
<div :class="{'actions-div': true, 'offline': !online || !video}" >
<Backward @click="backward" :class="{'playIcon': online && video, 'itm-sec': true, 'enabled': online}" class="itm-sec" icon="fa-solid fa-backward-step"/>
<div @click="togglePause()" :class="{'playIcon': online && video, 'itm-main': true}">
<Play v-if="pause || !online"/>
<Pause v-else/>
</div>
<Forward @click="forward" :class="{'playIcon': online && video, 'itm-sec': true, 'enabled': online}" class="itm-sec" icon="fa-solid fa-forward-step"/>
</div>
</span>
<ActualChannel/>
<div class="button-div" v-if="online" >
<CustomIcon :active="loop" @click="toggleLoop"><Loop active icon="fa-solid fa-sync"/></CustomIcon>
<CustomIcon :active="shuffle" @click="toggleShuffle"><Shuffle icon="fa-solid fa-shuffle"/></CustomIcon>
<CustomIcon @click="changeChannel"><Hand icon="fa-solid fa-hand"/></CustomIcon>
<CustomIcon @click="disconnect"><Disconnect icon="fa-solid fa-phone-slash"/></CustomIcon>
</div>
</div>
<div :class="{'player-mobile-bar': playerOpen, 'player-mobile-fold': !playerOpen}">
<div v-if="online" ref="playerMobileToggle">
<IconAction v-if="!playerOpen" icon="fa-angle-up"/>
<IconAction v-else icon="fa-angle-down"/>
</div>
<VideoList v-if="!playerOpen" mobile :player-offline="!online" :video="video" />
<Video mobile v-else :video="video" />
</div>
</section>
<section v-else ref="playerMobile" class="player-mobile offline">
<span class="offline-message">
<p>Subsonics est hors-ligne</p>
<p class="text-secondary">Connectez vous à un salon vocal du serveur Discord : {{ globalStore.actualServer ? globalStore.actualServer.name : '' }}</p>
</span>
<ActualChannel/>
</section>
</div>
</div>
</template>
<script setup>
import Play from '@/assets/Icons/Play.vue';
import Box from '../UI/Box.vue';
import DurationBar from '../UI/DurationBar.vue';
import VideoList from "../UI/VideoList.vue";
import { onMounted, ref, watch } from 'vue';
import Forward from '@/assets/Icons/Forward.vue';
import Backward from '@/assets/Icons/Backward.vue';
import Loop from '@/assets/Icons/Loop.vue';
import Shuffle from '@/assets/Icons/Shuffle.vue';
import Hand from '@/assets/Icons/Hand.vue';
import Disconnect from '@/assets/Icons/Disconnect.vue';
import CustomIcon from '../UI/CustomIcon.vue';
import Pause from '@/assets/Icons/Pause.vue';
import IconAction from '../UI/IconAction.vue';
import Video from '../UI/Video.vue';
import Tag from '../UI/Tag.vue';
import { onClickOutside } from '@vueuse/core';
import { useGlobalStore } from '@/stores/globalStore';
import { IOListener, IORequest } from '@/utils/IORequest';
import { useResizeObserver } from '@vueuse/core'
import ActualChannel from '../Widget/View/Player/ActualChannel.vue';
import { socket } from '@/socket';
import Events from '@/utils/Events';
const globalStore = useGlobalStore();
const playerInstance = ref(null);
const currentTime = ref(0);
const totalDuration = ref(0);
const pause = ref(true);
const online = ref(false); // This should be set based on the actual online status of the player
const loop = ref(false);
const shuffle = ref(false);
const video = ref(null);
const playerOpen = ref(false);
const durationBar = ref(null);
const buffering = ref(false);
//TODO: Rework Animation Both Side
//FIXME: Animation weird 550px
const playerMobile = ref(null);
const playerMobileToggle = ref(null);
const playerHeight = ref(0);
IOListener("/PLAYER/UPDATE", (response) => {
updatePlayer(response)
})
Events.on("GUILD_JOINED", () => {
askUpdate()
})
Events.on("player:needUpdate", () => {
Events.emit("player:update", playerInstance.value);
})
function askUpdate() {
IORequest("/PLAYER/STATE", (response) => {
console.log("Ask Player Update")
updatePlayer(response)
})
}
function updatePlayer(player) {
if(player) {
online.value = player.playerState;
pause.value = player.paused;
currentTime.value = player.duration ? player.duration : 0;
totalDuration.value = player.current ? player.current.duration : 0;
video.value = player.current;
shuffle.value = player.shuffle;
loop.value = player.loop;
buffering.value = player.playerState == "buffering";
if(durationBar.value) {
durationBar.value.updateLocalValues();
}
playerInstance.value = player;
} else {
online.value = false;
pause.value = true;
currentTime.value = 0;
totalDuration.value = 0;
video.value = null;
shuffle.value = false;
loop.value = false;
buffering.value = false;
playerInstance.value = null;
}
Events.emit("player:update", player);
}
function togglePlayerOpen(event) {
if(!playerOpen.value || playerMobileToggle.value.contains(event.target)) {
if (playerOpen.value) {
// player est ouvert → on ferme avec animation
const container = playerMobile.value.parentElement; // .player-mobile-container
container.classList.add('slide-out');
container.addEventListener('animationend', () => {
container.classList.remove('player-mobile-container-open');
container.classList.remove('slide-out');
playerOpen.value = false;
}, { once: true });
} else {
// player est fermé → on ouvre normalement
playerOpen.value = true;
playerMobile.value.parentElement.classList.add('player-mobile-container-open');
}
}
}
function togglePause() {
IORequest("/PLAYER/PAUSE", (data) => {
console.log("Player pause toggled:", data);
})
}
function toggleLoop() {
IORequest("/PLAYER/LOOP", (data) => {
console.log("Player loop toggled:", data);
})
}
function toggleShuffle() {
IORequest("/PLAYER/SHUFFLE", (data) => {
console.log("Player shuffle toggled:", data);
})
}
function disconnect() {
IORequest("/PLAYER/DISCONNECT", (data) => {
console.log("Player disconnected:", data);
})
}
function changeChannel() {
IORequest("/PLAYER/CHANNEL/CHANGE", (data) => {
console.log("Player channel changed:", data);
})
}
function backward() {
IORequest("/PLAYER/BACKWARD", (data) => {
console.log("Player backward:", data);
})
}
function forward() {
IORequest("/PLAYER/FORWARD", (data) => {
console.log("Player forward:", data);
})
}
onMounted(() => {
if(video.value) {
totalDuration.value = video.value.duration;
}
socket.removeListener("/PLAYER/STATE")
askUpdate()
})
useResizeObserver(playerMobile, (entries) => {
if(playerOpen.value) return;
playerHeight.value = playerMobile.value.offsetHeight;
})
onClickOutside(playerMobile, () => {
if (playerOpen.value) {
playerOpen.value = false;
}
});
function seek(time) {
IORequest("/PLAYER/SEEK", (data) => {
console.log("Player seeked:", data);
}, { time: time })
}
watch(() => video.value, () => {
if(video.value) {
totalDuration.value = video.value.duration;
}
});
</script>
<style scoped>
.button-div {
display: flex;
align-items: center;
gap: 20px;
margin: 0px 10px;
justify-content: center;
}
.player-container {
flex: 1;
transition: all 0.3s ease-in-out;
}
.duration-bar {
margin: 0 10px ;
}
.actions {
display: flex;
justify-content: space-between;
gap: 10px;
width: 100%;
}
.offline {
--main: var(--tertiary);
--text: #757575 ;
}
.enabled {
--main: var(--main-hover) !important;
}
.player {
display: flex;
flex-direction: column;
justify-content: space-between;
flex: 1;
}
.playIcon {
--text: #ffffff;
}
.itm-sec {
cursor: pointer;
}
.itm-sec:hover && .playIcon {
--main: var(--main-hover);
}
.itm-sec:active && .playIcon {
--main: var(--main-active);
}
.itm-main {
padding: 15px;
border-radius: 100%;
background: var(--main);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease-in-out;
}
.itm-main:hover && .playIcon {
background: var(--main-hover);
}
.itm-main:active && .playIcon {
background: var(--main-active);
}
.actions-div {
display: flex;
align-items: center;
gap: 15px;
margin: 0px 10px;
}
.player-deck {
display: flex;
flex: 1;
}
.player-mobile {
display: none;
}
.player-box {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.player-placeholder {
display: none;
}
.player-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
.player-mobile {
display: flex;
justify-content: center;
align-items: center;
background-color: var(--main);
padding: 10px;
gap: 15px;
font-size: 0.7em;
border-radius: 10px;
margin: 0px 15px 15px 15px;
transition: all 0.3s ease;
flex: 1;
}
.player-mobile-container-open {
animation: slideIn 0.5s forwards;
}
@keyframes slideIn {
from{
bottom: -500px;
}
to {
bottom: 0;
}
}
.player-mobile-container-open.slide-out {
animation: slideOut 0.5s forwards ease-in-out;
}
@keyframes slideOut {
from {
bottom: 0;
}
to {
bottom: -400px; /* même valeur que slideIn mais inversée */
}
}
.enabled {
--main: var(--text) !important;
}
.offline {
justify-content: space-between;
}
.offline-message p {
font-size: 1.1em;
margin: 0;
}
.offline-message {
display: flex;
flex-direction: column;
gap: 5px;
width: 50%;
}
.text-secondary {
color: var(--text);
font-size: 0.9em !important;
}
.actions-container{
display: flex;
align-items: center;
justify-content: center;
}
.player-mobile-container {
width: 100vw;
z-index: 2000;
position: fixed;
display: flex;
bottom: 0;
}
.player-placeholder {
display: block;
margin: 0px 15px 15px 15px;
}
.player-menu {
width: 100%;
display: flex;
flex-direction: column;
gap: 25px;
}
.player-mobile-unfold {
margin: 0 !important;
border-radius: 10px 10px 0 0 !important;
flex-direction: column-reverse;
justify-content: space-between;
z-index: 2000;
position: relative;
overflow: hidden;
padding: 10px 10px 20px 10px;
background-color: var(--tertiary);
}
.player-mobile-fold {
display: flex;
flex-direction: row-reverse;
gap: 10px;
align-items: center;
width: 100%;
}
.player-mobile-bar {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
width: 100%;
}
.player {
flex: 0;
}
.itm-main {
--main: var(--quaternary);
--text: var(--tertiary);
}
.playIcon {
--main: var(--primary-inverse);
--text: var(--text-inverse);
}
.itm-sec {
--text: var(--primary-inverse);
}
.player-deck {
display: none;
}
}
</style>

View File

@@ -0,0 +1,197 @@
<template>
<div class="playlist">
<!-- <p class="title"><Icon icon="fa-list-ol"/> Playlists</p> -->
<div class="playlist-items">
<div v-if="playlists.length > 0" class="playlist-items-list">
<PlaylistItem :selected="globalStore.actualPlaylistId === value.playlistId" @click="Events.emit('playlist:open', value)" v-for="value in playlists" :key="value.playlistId" :title="value.title" :type="value.type"/>
</div>
<div class="none" v-else>
<p class="none-title"><Icon icon="fa-circle-xmark"/>Aucune Playlist</p>
<p class="none-info">
Vous n'avez pas encore créé de Playlist. <br/> Cliquez sur le bouton ci-dessous pour en ajouter une.
</p>
</div>
</div>
<Button class="add-playlist" @click="openModal()"><Icon icon="fa-add"/> Ajouter une playlist</Button>
</div>
<Modal icon="fa-list" title="Ajouter une Playlist" ref="modal">
<div class="p-modal-content">
<input type="text" v-if="!isYoutube" v-model="newPlaylistTitle" placeholder="Titre de la playlist" />
<input type="text" v-if="isYoutube" v-model="urlLink" placeholder="Lien de la playlist Youtube" />
<div @click="isYoutube = !isYoutube" class="youtube-import">
<input type="checkbox" v-model="isYoutube" />
<label>Importer depuis Youtube</label>
</div>
<Button :disabled="(!isYoutube && newPlaylistTitle.trim() === '') || (isYoutube && urlLink?.trim() === '')" @click="addPlaylist">Ajouter</Button>
<p class="text-loading" v-if="isLoading"><Icon icon="fa-spinner" spin-pulse /> Création en cours...</p>
<p class="text-loading" v-if="error"><Error> {{ error }}</Error></p>
</div>
</Modal>
</template>
<script setup>
import { IORequest } from '@/utils/IORequest';
import Button from '../UI/Button.vue';
import PlaylistItem from '../UI/PlaylistItem.vue';
import { onMounted, ref, onUnmounted } from 'vue';
import Modal from '../UI/Modal.vue';
import Error from '../UI/Error.vue';
import Events from '@/utils/Events';
import { useGlobalStore } from '@/stores/globalStore';
import { useUserStore } from '@/stores/userStore';
import PlaylistView from '@/components/Widget/View/Playlist/PlaylistView.vue';
const modal = ref(null);
const playlists = ref([]);
const newPlaylistTitle = ref('');
const isYoutube = ref(false);
const urlLink = ref(null);
const isLoading = ref(false);
const error = ref(null);
const globalStore = useGlobalStore();
const userStore = useUserStore();
//FIXME: Fix Ralentissement lors de grosses Playlists
//TODO: Faire la synchro google
//TODO: Faire la possibilité d'envoyer une playlist
function openModal() {
modal.value.open();
newPlaylistTitle.value = '';
isYoutube.value = false;
urlLink.value = null;
isLoading.value = false;
error.value = null;
}
Events.on("playlist:init", (data) => {
playlists.value = data;
})
onMounted(() => {
Events.emit("logic:init");
});
function addPlaylist() {
if ((!isYoutube && newPlaylistTitle.value?.trim() === '') || (isYoutube && urlLink.value?.trim() === '')) {
return;
}
if(isYoutube.value) newPlaylistTitle.value = urlLink.value;
isLoading.value = true;
IORequest("/PLAYLISTS/CREATE", (response) => {
isLoading.value = false;
if (response) {
Events.emit("logic:init");
modal.value.close();
} else {
error.value = "La playlist n'a pas pu être créée. Vérifier l'URL renseignée.";
}
newPlaylistTitle.value = '';
isYoutube.value = false;
urlLink.value = null;
}, { name: newPlaylistTitle.value, url: urlLink.value });
}
</script>
<style scoped>
label {
color: var(--text-secondary);
}
.text-loading {
color: var(--text-secondary);
display: flex;
align-items: center;
font-size: 0.9em;
gap: 5px;
cursor: pointer;
text-align: center;
justify-content: center;
}
.p-modal-content {
display: flex;
flex-direction: column;
gap: 10px;
}
.playlist {
flex: 1;
display: flex;
gap: 10px;
flex-direction: column;
}
.playlist-items {
flex: 1;
overflow-y: auto;
display: flex;
position: relative;
}
@media screen and (max-width: 768px),
screen and (max-height: 607) {
.playlist-items {
flex-direction: column;
}
}
.playlist-items-list {
position: absolute;
display: flex;
flex-direction: column;
gap: 10px;
}
.add-playlist {
width: 100%;
}
p {
margin: 2px 0px;
}
.title {
font-size: 18px;
margin: 0;
}
.none {
text-align: center;
color: var(--text-secondary);
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
font-size: 1.5em;
justify-content: center;
flex: 1;
}
.none-title {
gap: 10px;
display: flex;
align-items: center;
justify-content: center;
}
.none-info {
text-align: center;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 5px;
font-size: 0.6em;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,132 @@
<template>
<section class="queue-div">
<Glider v-model="selectedTab">
<span>Liste de lecture</span>
<span>Historique</span>
</Glider>
<div class="list-wrapper">
<Transition :name="direction">
<component :is="selectedTab === 0 ? NextList : PreviousList" :key="selectedTab" :online="online" :nextList="nextList" :previousList="previousList"></component>
</Transition>
</div>
</section>
</template>
<script setup>
import { useRouter } from 'vue-router';
import { useGlobalStore } from '@/stores/globalStore';
import { useUserStore } from '@/stores/userStore';
import { IORequest } from '@/utils/IORequest';
import Glider from '../UI/Glider.vue';
import VideoList from '../UI/VideoList.vue';
import { onMounted, ref, watch } from 'vue';
import Events from '@/utils/Events';
import IconAction from '../UI/IconAction.vue';
import { useId } from "vue"
import VideoQueue from '../UI/VideoQueue.vue';
import NextList from '../Widget/Queue/NextList.vue';
import PreviousList from '../Widget/Queue/PreviousList.vue';
const router = useRouter();
const globalStore = useGlobalStore();
const userStore = useUserStore();
const online = ref(false);
const nextList = ref(null)
const previousList = ref(null)
const direction = ref('forward')
const id = useId()
const selectedTab = ref(0)
Events.on("player:update", (player) => {
if(player) nextList.value = player.next;
if(player) previousList.value = player.previous;
if(player) online.value = player.playerState
})
onMounted(() => {
Events.emit("player:needUpdate");
})
watch(selectedTab, (newTab, oldTab) => {
if (newTab > oldTab) {
direction.value = 'forward'; // glide vers la gauche
} else if (newTab < oldTab) {
direction.value = 'backward'; // glide vers la droite
}
});
</script>
<style scoped>
.list-wrapper {
position: relative; /* conteneur pour les sections absolues */
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: auto;
flex: 1;
display: flex;
}
/* Forward animation */
.forward-enter-active {
animation: slide-left-in 0.3s forwards;
}
.forward-leave-active {
animation: slide-left-out 0.3s forwards;
}
/* Backward animation */
.backward-enter-active {
animation: slide-right-in 0.3s forwards;
}
.backward-leave-active {
animation: slide-right-out 0.3s forwards;
}
/* Keyframes */
@keyframes slide-left-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slide-left-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(-100%); opacity: 0; }
}
@keyframes slide-right-in {
from { transform: translateX(-100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slide-right-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
/* Ce bloc empêche l'animation au premier montage */
.slide-appear-from,
.slide-appear-to,
.slide-appear-active {
transition: none !important;
}
.queue-div {
display: flex;
flex: 1;
flex-direction: column;
background-color: var(--background);
border-radius: 9px;
gap: 10px;
}
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
}
</style>

View File

@@ -1,12 +1,22 @@
<template> <template>
<div class="search"> <div class="search">
<Icon color="#FFFFFF" icon="fa-solid fa-magnifying-glass" style="width: 20px;" /> <Icon color="#FFFFFF" icon="fa-solid fa-magnifying-glass" style="width: 20px;" />
<input ref="searchBar" type="text" placeholder="Insérer votre recherche ici" v-model="searchQuery" /> <input name="search" ref="searchBar" type="text" placeholder="Insérer votre recherche ici" v-model="searchQuery" />
<IconAction <div class="search-actions">
icon="fa-solid fa-cloud-arrow-up" <IconAction
color="#FFFFFF" icon="fa-solid fa-cloud-arrow-up"
title="Mes fichiers" color="#FFFFFF"
/> title="Mes fichiers"
@click="Events.emit('VIEW_FILES')"
/>
<IconAction
icon="fa-solid fa-clipboard-list"
title="Lire les informations"
class="docs-icon"
color="#FFFFFF"
@click="Events.emit('VIEW_INFO')"
/>
</div>
</div> </div>
</template> </template>
@@ -17,23 +27,32 @@ import { IORequest } from '@/utils/IORequest';
import Events from '@/utils/Events'; import Events from '@/utils/Events';
const searchBar = ref(null); const searchBar = ref(null);
const searchQuery = ref(''); const searchQuery = ref('');
onMounted(() => { onMounted(() => {
searchBar.value.addEventListener('change', find); searchBar.value.addEventListener('change', find);
}); });
//TODO: Faire un systême de suggestions.
function find() { function find() {
// If on mobile close the keyboard
if (window.innerWidth < 768) {
searchBar.value.blur();
}
if (searchQuery.value.trim() === '') { if (searchQuery.value.trim() === '') {
return; return;
} }
Events.emit("SEARCH_STARTED"); Events.emit("SEARCH_STARTED");
IORequest("/SEARCH", (data) => { IORequest("/SEARCH", (data) => {
Events.emit("SEARCH_RESULT", data); Events.emit("SEARCH_RESULT", {data: data, query: searchQuery.value});
}, searchQuery.value); }, searchQuery.value);
} }
Events.on("VIEW_CLOSED", () => {
searchQuery.value = '';
});
</script> </script>
@@ -63,4 +82,21 @@ function find() {
.search input::placeholder { .search input::placeholder {
color: var(--text-secondary); color: var(--text-secondary);
} }
.docs-icon {
display: none;
}
.search-actions {
display: flex;
align-items: center;
gap: 10px;
}
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
.docs-icon {
display: block;
}
}
</style> </style>

View File

@@ -1,71 +1,170 @@
<template> <template>
<Box v-if="deviceType === 'desktop'" padding="closed" class="view"> <Box padding="closed" class="view">
<IconAction @click="returnDefault()" v-if="!actualComponent.unclosable" icon="fa-solid fa-xmark" class="close"/> <IconAction
<component :is="actualComponent.component" v-bind="actualComponent.props"/> :class="actualComponent.onlyMobile ? 'hideOnMobile' : ''"
</Box> @click="returnDefault()"
<div v-else-if="deviceType === 'mobile' && !actualComponent.default" class="view-mobile"> v-if="!actualComponent.unclosable"
<IconAction @click="returnDefault()" v-if="!actualComponent.unclosable" icon="fa-solid fa-xmark" class="close"/> icon="fa-solid fa-xmark"
<component :is="actualComponent.component" v-bind="actualComponent.props"/> class="close"
</div> />
<!-- Écran de chargement -->
<LoadingView v-if="isLoading" :message="loadingMessage" />
<!-- Composant réel (pré-chargé mais masqué tant que loading) -->
<component
v-show="!isLoading"
:is="actualComponent.component"
v-bind="actualComponent.props"
/>
</Box>
</template> </template>
<script setup> <script setup>
import Box from '../UI/Box.vue'; import Box from '../UI/Box.vue';
import Events from '@/utils/Events'; import Events from '@/utils/Events';
import { computed, ref, watch } from 'vue'; import { shallowRef, ref, watch, onMounted } from 'vue';
import Loading from '@/components/Widget/View/LoadingView.vue'
import LoadingView from '@/components/Widget/View/LoadingView.vue';
import SearchResults from '@/components/Widget/View/SearchResults.vue'; import SearchResults from '@/components/Widget/View/SearchResults.vue';
import Default from '../Widget/View/Default.vue'; import Default from '../Widget/View/Default.vue';
import IconAction from '../UI/IconAction.vue'; import IconAction from '../UI/IconAction.vue';
import { useGlobalStore } from '@/stores/globalStore';
//TODO: Render compatibility with mobile import UploadFiles from '../Widget/View/UploadFiles.vue';
//TODO: Make Default.vue a real default view import PlaylistView from '../Widget/View/Playlist/PlaylistView.vue';
//TODO: Make the search request import Advice from '../Widget/View/Home/Advice.vue';
//TODO: Make the medias import History from '../Widget/View/Home/History.vue';
const deviceType = ref((window.innerWidth < 768 || window.innerHeight < 607) ? 'mobile' : 'desktop');
watch(() => window.innerWidth, () => {
deviceType.value = (window.innerWidth < 768 || window.innerHeight < 607) ? 'mobile' : 'desktop';
});
const defaultComponent = { const defaultComponent = {
component: Default, component: Default,
unclosable: true, unclosable: true,
default: true, default: true,
props: {} props: {},
}; };
function returnDefault() { const globalStore = useGlobalStore();
actualComponent.value = defaultComponent; const actualComponent = shallowRef(defaultComponent);
}
const actualComponent = ref(defaultComponent); // Nouveaux états
const isLoading = ref(false);
const loadingMessage = ref("");
Events.on("SEARCH_STARTED", () => { onMounted(() => {
setLoading("Recherche en cours ...") globalStore.isViewShowing = false;
})
Events.on("SEARCH_RESULT", (data) => {
console.log("Search results received:", data);
actualComponent.value = {
component: SearchResults,
props: {
results: data
}
};
}); });
function setLoading(messageLoading) { watch(() => actualComponent.value, (value) => {
Events.emit('view:change', value.component);
actualComponent.value = { globalStore.isViewShowing = !(value && value.default);
component: Loading, });
unclosable: true,
props: {
message: messageLoading
}
};
function returnDefault() {
Events.emit("VIEW_CLOSED");
actualComponent.value = defaultComponent;
} }
function setLoading(message) {
loadingMessage.value = message;
isLoading.value = true;
}
function finishLoading() {
isLoading.value = false;
}
// === EVENTS ===
Events.on("SEARCH_STARTED", () => {
setLoading("Recherche en cours");
});
Events.on("SEARCH_RESULT", async (data) => {
setLoading("Chargement des résultats de recherche...");
const isPlaylist = data?.data?.form === "PLAYLIST";
const isOneSong = data?.data?.form === "SONG";
if (isOneSong) data.data = [data.data];
actualComponent.value = {
component: SearchResults,
props: {
results: data.data,
playlist: isPlaylist,
query: data.query,
},
};
finishLoading(); // on enlève le loader seulement une fois que le composant est prêt
});
Events.on("VIEW_INFO", () => {
setLoading("Chargement des informations...");
if (actualComponent.value.onlyMobile) {
returnDefault();
finishLoading();
return;
}
actualComponent.value = {
component: Default,
onlyMobile: true,
props: {}
};
finishLoading();
});
Events.on("VIEW_FILES", () => {
setLoading("Chargement des fichiers...");
actualComponent.value = {
component: UploadFiles,
props: {}
};
finishLoading();
});
Events.on("VIEW_PLAYLIST", (data) => {
setLoading("Chargement de la playlist...");
actualComponent.value = {
component: PlaylistView,
props: {
playlist: data.playlist,
hasBeenActualized: data.hasBeenActualized,
},
};
finishLoading();
});
Events.on("VIEW_RESET", () => {
returnDefault();
finishLoading();
});
Events.on("VIEW_HELP", () => {
setLoading("Chargement de l'aide...");
actualComponent.value = {
component: Advice,
props: {}
};
finishLoading();
});
Events.on("VIEW_HISTORY", () => {
setLoading("Chargement de l'historique...");
actualComponent.value = {
component: History,
props: {}
};
finishLoading();
});
</script> </script>
<style scoped> <style scoped>
@@ -73,23 +172,24 @@ Events.on("SEARCH_RESULT", (data) => {
position: absolute; position: absolute;
top: 10px; top: 10px;
right: 10px; right: 10px;
color: var(--text-secondary); color: var(--primary-inverse);
cursor: pointer; cursor: pointer;
z-index: 500;
} }
.view { .view {
position: relative; position: relative;
} }
.view-mobile { .hideOnMobile {
position: fixed; display: none;
top: 0; }
left: 0;
width: 100%; @media screen and (max-width: 768px),
height: 100%; screen and (max-height: 607px) {
background-color: var(--background-secondary); .hideOnMobile {
z-index: 1000; display: block !important;
border-radius: 0 !important; }
} }
</style> </style>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div> <div>
<img :src="`https://cdn.discordapp.com/avatars/${userId}/${avatarUrl}`" alt='User Avatar'> <img :class="imgClass ? imgClass : mobile ? 'avatar-mobile' : 'img'" :style="width ? `width: ${width}; height: ${height};` : ''" :src="`https://cdn.discordapp.com/avatars/${userId}/${avatarUrl}`" alt='User Avatar'>
<Icon v-if="tag === 'admin'" style="color: var(--admin-color);" icon="fa-solid fa-star" class="tag" /> <Icon v-if="tag === 'admin'" style="color: var(--admin-color);" icon="fa-solid fa-star" class="tag" />
<Icon v-if="tag === 'owner'" style="color: var(--owner-color);" icon="fa-solid fa-crown" class="tag" /> <Icon v-if="tag === 'owner'" style="color: var(--owner-color);" icon="fa-solid fa-crown" class="tag" />
<Icon v-if="tag === 'mod'" style="color: var(--mod-color);" icon="fa-solid fa-shield-halved" class="tag" /> <Icon v-if="tag === 'mod'" style="color: var(--mod-color);" icon="fa-solid fa-shield-halved" class="tag" />
@@ -32,6 +32,22 @@ const props = defineProps({
isAdmin: { isAdmin: {
type: Boolean, type: Boolean,
default: false default: false
},
width: {
type: String,
default: null
},
height: {
type: String,
default: null
},
imgClass: {
type: String,
default: ''
},
mobile: {
type: Boolean,
default: false
} }
}); });
@@ -44,7 +60,7 @@ const tag = computed(() => {
</script> </script>
<style scoped> <style scoped>
img { .img {
border-radius: 100%; border-radius: 100%;
width: 50px; width: 50px;
height: 50px; height: 50px;
@@ -52,6 +68,10 @@ img {
background-color: var(--main); background-color: var(--main);
} }
img {
border-radius: 100%;
}
div { div {
position: relative; position: relative;
display: inline-block; display: inline-block;
@@ -66,4 +86,21 @@ div {
border-radius: 50%; border-radius: 50%;
padding: 2px 5px; padding: 2px 5px;
} }
.avatar-welcome {
width: 100px !important;
height: 100px !important;
object-fit: cover;
background-color: var(--main);
}
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
.avatar-welcome {
width: 80px !important;
height: 80px !important;
}
}
</style> </style>

View File

@@ -10,7 +10,7 @@
} }
.box-shadow { .box-shadow {
box-shadow: 2px 2px 10px 0px rgba(0, 0, 0, 0.50); box-shadow: 0px 0px 10px 0px rgba(0, 0, 0, 0.35);
} }

View File

@@ -1,5 +1,6 @@
<template> <template>
<button :disabled="props.disabled" :class="disableClass" @click="$emit('click')"> <button :disabled="props.disabled" :class="disableClass" @click="$emit('click')">
<Icon v-if="props.icon" :icon="props.icon" />
<slot></slot> <slot></slot>
</button> </button>
</template> </template>
@@ -14,6 +15,10 @@ const props = defineProps({
colorLower: { colorLower: {
type: Boolean, type: Boolean,
default: false default: false
},
icon: {
type: String,
default: null
} }
}); });
@@ -21,6 +26,8 @@ const disableClass = computed(() => {
return props.disabled ? props.colorLower ? 'disabled color-lower' : 'disabled' : ''; return props.disabled ? props.colorLower ? 'disabled color-lower' : 'disabled' : '';
}); });
//TODO: Refactor every button component to use the icon prop
</script> </script>
<style scoped> <style scoped>
button { button {

View File

@@ -0,0 +1,273 @@
<template>
<div class="carousel">
<div ref="wrapper" class="component-wrapper">
<IconAction v-if="!disableArrow" class="carousel-arrow" icon="fa-solid fa-arrow-left" @click="changeSlot((actualIndex - 1 + allSlots.length) % allSlots.length, 'backward')" />
<span class="component">
<Transition :name="direction" appear mode="out-in" backwards>
<span class="component-child" v-if="actualComponent" :key="actualIndex">
<component :is="actualComponent"></component>
</span>
<p v-else :key="'empty'"><Error>Aucun composant trouvé</Error></p>
</Transition>
</span>
<IconAction v-if="!disableArrow" class="carousel-arrow" icon="fa-solid fa-arrow-right" @click="changeSlot((actualIndex + 1) % allSlots.length, 'forward')" />
</div>
<div v-if="allSlots.length > 1 && ready" :class="props.onlyMobile ? 'onlymobile selector' : 'selector'">
<IconAction @click="() => {changeSlot(allSlots.indexOf(slot))}" :class="{'notactive': actualIndex !== allSlots.indexOf(slot), 'point': true }" v-for="slot in allSlots" :key="allSlots.indexOf(slot)" v-if="allSlots" icon="fa-solid fa-circle" />
</div>
</div>
</template>
<script setup>
import { ref, useSlots, onMounted, watch, onUnmounted, nextTick } from 'vue';
import Error from '@/components/UI/Error.vue';
import IconAction from './IconAction.vue';
const actualIndex = ref(0);
const actualComponent = ref(null);
const wrapper = ref(null);
const direction = ref('')
const ready = ref(false);
const slots = useSlots();
const allSlots = ref(slots.default ? slots.default() : []);
const props = defineProps({
disableArrow: {
type: Boolean,
default: false
},
autoPlay: {
type: Boolean,
default: true
},
onlyMobile: {
type: Boolean,
default: false
}
});
// Detect the swipe of mobile
function changeSlot(newIndex, dir) {
if(!dir) {
dir = newIndex > actualIndex.value ? 'forward' : 'backward';
}
direction.value = dir;
actualIndex.value = newIndex;
}
function handleTouchStart(ev) {
const touch = ev.touches[0];
const startX = touch.clientX;
function handleTouchMove(ev) {
const touch = ev.touches[0];
const deltaX = touch.clientX - startX;
if (deltaX > 5) {
changeSlot((actualIndex.value - 1 + allSlots.value.length) % allSlots.value.length, "backward");
} else if (deltaX < -5) {
changeSlot((actualIndex.value + 1) % allSlots.value.length, "forward");
}
wrapper.value.removeEventListener('touchmove', handleTouchMove, false);
}
wrapper.value.addEventListener('touchmove', handleTouchMove, false);
}
watch(() => actualIndex.value, (newIndex) => {
if (allSlots.value[newIndex]) {
actualComponent.value = allSlots.value[newIndex];
} else {
actualComponent.value = null;
}
});
onUnmounted(() => {
actualIndex.value = 0; // Reset index when component is unmounted
if(!props.autoPlay) return;
});
onMounted(() => {
actualComponent.value = allSlots.value[0] || null;
// Ajouter un écouteur pour l'événement load
window.addEventListener('load', () => {
nextTick(() => {
updateSlots();
});
});
// Ajouter un délai pour vérifier la taille de la fenêtre
setTimeout(() => {
nextTick(() => {
updateSlots();
});
}, 10);
wrapper.value.addEventListener('touchstart', handleTouchStart, false);
})
window.addEventListener('resize', () => {
nextTick(() => {
updateSlots();
});
});
function updateSlots() {
setTimeout(() => {
allSlots.value = slots.default ? slots.default() : [];
if(props.onlyMobile) {
if (window.innerWidth > 1280) {
direction.value = '';
actualIndex.value = 0; // Reset index when resizing to desktop
return;
} else {
if(window.innerWidth > 769 && window.innerHeight > 607) {
if(actualComponent.value.props.mobile == '') {
actualIndex.value = 0; // Reset index when resizing to desktop
}
}
allSlots.value.forEach((slot, index) => {
if (slot.props.mobile == '' && (window.innerWidth > 769 && window.innerHeight > 607)) {
allSlots.value.splice(index, 1);
}
});
}
}
ready.value = true;
}, 100)
}
</script>
<style scoped>
.notactive {
opacity: 0.3;
transition: all 0.2s ease-out;
}
.selector {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-51%);
display: flex;
gap: 5px;
transition: all 0.3s ease;
}
.point {
font-size: 12px;
}
.onlymobile {
display: none !important;
}
.component-wrapper {
display: flex;
align-items: center;
flex: 1;
gap: 20px;
width: 100%;
max-height: 100%;
position: relative;
}
.carousel {
display: flex;
flex-direction: column;
flex: 1;
width: 100%;
max-height: 100%;
position: relative;
}
.component {
flex: 1;
align-items: start;
overflow-y: auto;
width: 100%;
height: 100%;
max-height: 100%;
position: relative;
overflow-x: hidden;
}
.component-child {
position: absolute;
width: 100%;
height: 100%;
display: flex;
}
/* Forward animation */
.forward-enter-active {
animation: slide-left-in 0.3s forwards;
}
.forward-leave-active {
animation: slide-left-out 0.3s forwards;
}
/* Backward animation */
.backward-enter-active {
animation: slide-right-in 0.3s forwards;
}
.backward-leave-active {
animation: slide-right-out 0.3s forwards;
}
/* Keyframes */
@keyframes slide-left-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slide-left-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(-100%); opacity: 0; }
}
@keyframes slide-right-in {
from { transform: translateX(-100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slide-right-out {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
/* Ce bloc empêche l'animation au premier montage */
.slide-appear-from,
.slide-appear-to,
.slide-appear-active {
transition: none !important;
}
@media screen and (max-width: 1280px),
screen and (max-height: 607px) {
.carousel-arrow {
display: none;
}
.onlymobile {
display: flex !important;
position: relative;
margin-top: 10px;
justify-content: center;
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<div class="icon-action">
<span class="icon"><slot></slot></span>
<span v-if="active" class="active-indicator"></span>
</div>
</template>
<script setup>
const props = defineProps({
active: {
type: Boolean,
default: false
}
});
</script>
<style scoped>
.icon {
font-size: 1.3em;
transition: filter 0.3s ease;
}
.fixed {
font-size: 1.3em;
transition: filter 0.3s ease;
}
.icon-action {
position: relative;
}
.active-indicator {
position: absolute;
border-radius: 100%;
width: 4px;
height: 4px;
bottom: -15%;
left: 50%;
transform: translate(-50%, 0);
background: var(--text);
}
.icon:hover {
cursor: pointer;
filter: brightness(0.8);
}
.icon:active {
cursor: pointer;
filter: brightness(0.7);
}
</style>

View File

@@ -0,0 +1,275 @@
<template>
<div v-if="!buffering" class="duration-bar">
<p v-if="!offline && localCurrentTime != 0">{{ getVideoDuration(localCurrentTime) }}</p>
<p class="offline" v-else>-:--</p>
<div ref="progressBar" :class="{'progress-bar': true, 'progress-bar-mobile': mobile}" @click="seek" @mousemove="hoverSeek" @mouseleave="hoverPercent = null" @pointerdown="startDrag">
<span class="progress" :style="{ width: percent + '%' }"></span>
<span class="progress-hover" v-if="hoverPercent !== null" :style="{ width: hoverPercent + '%' }"></span>
<span :class="{ 'progress-selector': true, 'progress-selector-active': !props.offline }" :style="{ left: percent + '%' }"></span>
</div>
<p v-if="!offline && localTotalDuration == 0"><Icon color="var(--text-error)" icon="fa-circle"/> LIVE</p>
<p v-else-if="!offline">{{ getVideoDuration(localTotalDuration) }}</p>
<p class="offline" v-else>-:--</p>
</div>
<div v-else>
<p class="offline">-:--</p>
<div class="multi-loader">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
<p class="offline">-:--</p>
</div>
</template>
<script setup>
import { getVideoDuration } from "@/utils/TimeConverter"
import { ref, computed, onMounted, watch, onBeforeUnmount } from "vue"
const props = defineProps({
currentTime: Number,
totalDuration: Number,
pause: Boolean,
offline: Boolean,
mobile: Boolean,
buffering: Boolean
})
const localCurrentTime = ref(props.offline ? 0 : props.currentTime)
const localTotalDuration = ref(props.offline ? 0 : props.totalDuration)
const progressBar = ref(null)
const isDragging = ref(false)
const emit = defineEmits(["seek"])
const percent = computed(() => {
if (!localTotalDuration.value) return 0
const p = (localCurrentTime.value / localTotalDuration.value) * 100
return Math.max(0, Math.min(100, p))
})
const hoverPercent = ref(null)
function seek(e) {
if(props.offline) return
const rect = e.currentTarget.getBoundingClientRect()
const clickPos = (e.clientX - rect.left) / rect.width
const newTime = clickPos * localTotalDuration.value
localCurrentTime.value = newTime
emit("seek", newTime)
}
function hoverSeek(e) {
if(props.offline) return
const rect = e.currentTarget.getBoundingClientRect()
let hoverCalcPercent = ((e.clientX - rect.left) / rect.width) * 100
hoverCalcPercent = Math.max(0, Math.min(100, hoverCalcPercent))
hoverPercent.value = hoverCalcPercent
// Add a tooltip or some indication of the current time like a title attribute
e.currentTarget.title = getVideoDuration((hoverCalcPercent / 100) * localTotalDuration.value)
}
// === DRAG LOGIC ===
function startDrag(e) {
if(props.offline) return
isDragging.value = true
updateDrag(e)
window.addEventListener("pointermove", updateDrag)
window.addEventListener("pointerup", stopDrag)
}
function updateDrag(e) {
if(props.offline) return
if (!isDragging.value) return
const rect = e.currentTarget?.getBoundingClientRect?.()
|| progressBar.value.getBoundingClientRect()
let pos = (e.clientX - rect.left) / rect.width
pos = Math.max(0, Math.min(1, pos))
const newTime = pos * localTotalDuration.value
localCurrentTime.value = newTime
}
function stopDrag(e) {
if(props.offline) return
if (!isDragging.value) return
isDragging.value = false
const rect = progressBar.value.getBoundingClientRect()
let pos = (e.clientX - rect.left) / rect.width
pos = Math.max(0, Math.min(1, pos))
const newTime = pos * localTotalDuration.value
localCurrentTime.value = newTime
emit("seek", newTime)
window.removeEventListener("pointermove", updateDrag)
window.removeEventListener("pointerup", stopDrag)
}
// === INIT ===
onMounted(() => {
if(!props.offline) {
localCurrentTime.value = props.currentTime
localTotalDuration.value = props.totalDuration
}
watch(() => props.offline, (newOffline) => {
if (newOffline) {
localCurrentTime.value = 0
localTotalDuration.value = 0
}
})
setInterval(() => {
if(!isDragging.value && localCurrentTime.value < localTotalDuration.value && !props.pause) {
localCurrentTime.value += 1
}
}, 1000)
})
watch(() => props.currentTime, (newTime) => {
if(props.offline) return
if (!isDragging.value) localCurrentTime.value = newTime
})
watch(() => props.totalDuration, (newDuration) => {
if(props.offline) return
localTotalDuration.value = newDuration
})
onBeforeUnmount(() => {
window.removeEventListener("pointermove", updateDrag)
window.removeEventListener("pointerup", stopDrag)
})
function updateLocalValues() {
if(props.offline) return
localCurrentTime.value = props.currentTime
localTotalDuration.value = props.totalDuration
}
defineExpose({
updateLocalValues
})
</script>
<style scoped>
.offline {
color: var(--text-secondary);
}
.duration-bar {
display: flex;
align-items: center;
gap: 10px;
font-family: monospace;
}
.progress-bar {
position: relative;
flex: 1;
height: 6px;
background: var(--tertiary);
border-radius: 3px;
cursor: pointer;
}
.progress-bar-mobile {
background: var(--quaternary);
}
.progress {
display: block;
position: absolute;
height: 100%;
max-width: 100%;
background: var(--main);
border-radius: 3px;
user-select: none;
pointer-events: none;
}
.progress-selector {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 12px;
height: 12px;
background: var(--main);
display: none;
border-radius: 50%;
animation: zoomIn 0.2s ease;
user-select: none;
pointer-events: none;
}
@keyframes zoomIn {
0% {
transform: translate(-50%, -50%) scale(0);
}
100% {
transform: translate(-50%, -50%) scale(1);
}
}
.progress-bar:hover .progress-selector-active {
display: block;
}
.progress-hover {
display: block;
height: 100%;
max-width: 100%;
background: var(--quaternary);
transition: left 0.1s ease;
border-radius: 3px;
}
.multi-loader {
position: relative;
width: 100%;
flex: 1;
height: 6px;
background: var(--tertiary);
border-radius: 4px;
overflow: hidden;
}
.multi-loader span {
position: absolute;
top: 0;
width: 40px;
height: 100%;
background: var(--main);
border-radius: 4px;
animation: move 2.5s cubic-bezier(0.4, 0, 0.2, 1) infinite;
}
@media screen and (max-width: 768px), screen and (max-height: 607px) {
.multi-loader {
background: var(--quaternary);
}
}
.multi-loader span:nth-child(1) { animation-delay: 0s; }
.multi-loader span:nth-child(2) { animation-delay: 0.3s; }
.multi-loader span:nth-child(3) { animation-delay: 0.6s; }
.multi-loader span:nth-child(4) { animation-delay: 0.9s; }
.multi-loader span:nth-child(5) { animation-delay: 1.2s; }
@keyframes move {
0% { left: -50px; width: 0px; opacity: 0; }
15% { opacity: 1; width: 40px; }
50% { width: 80px; } /* plus long au milieu */
85% { opacity: 1; width: 40px; }
90% { left: 100%; width: 10px; opacity: 0; }
100% { left: 100%; width: 0px; opacity: 0; }
}
</style>

View File

@@ -0,0 +1,90 @@
<script setup>
import { ref, useSlots, computed } from 'vue';
// on définit une prop spéciale pour le v-model
const props = defineProps({
modelValue: {
type: Number,
default: 0
}
});
const emit = defineEmits(['update:modelValue']);
const slots = useSlots();
const tabs = computed(() => slots.default?.() || []);
const hoveredTab = ref(null);
const selectedTab = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
});
const setSelectedTab = (index) => {
selectedTab.value = index;
};
</script>
<template>
<div
class="glider-container"
@mouseleave="hoveredTab = null"
>
<button
v-for="(tab, index) in tabs"
:key="index"
@click="setSelectedTab(index)"
@mouseenter="hoveredTab = index"
:class="['glider-tab', { 'glider-tab--selected': selectedTab === index }]"
>
<slot name="tab-content" :tab="tab" :index="index">
{{ tab.children }}
</slot>
</button>
<div
class="glider-indicator"
:style="{
left: `${(hoveredTab !== null ? hoveredTab : selectedTab) * (100 / tabs.length)}%`,
width: `${100 / tabs.length}%`
}"
></div>
</div>
</template>
<style scoped>
.glider-container {
width: 100%;
display: flex;
position: relative;
background-color: var(--quaternary);
border-radius: 20px;
overflow: hidden;
}
.glider-tab {
flex: 1;
padding: 6px 0;
position: relative;
z-index: 99;
background-color: transparent;
border: 0;
outline: none;
color: var(--text);
cursor: pointer;
font-weight: 500;
transition: color 0.2s ease-out;
}
.glider-tab--selected {
color: #FFFFFF !important;
}
.glider-indicator {
position: absolute;
height: 100%;
background: var(--main);
z-index: 9;
border-radius: 20px;
transition: left 0.2s ease-out, width 0.2s ease-out;
}
</style>

View File

@@ -1,5 +1,8 @@
<template> <template>
<Icon :style="{'font-size': size ? size : ''}" :color="color" :class="!fixed ? 'icon' : 'fixed'" :icon="icon" /> <div class="icon-action">
<Icon :style="{'font-size': size ? size : ''}" :color="color" :class="!fixed ? 'icon' : 'fixed'" :icon="icon" />
<span v-if="active" :style="{'background': color}" class="active-indicator"></span>
</div>
</template> </template>
<script setup> <script setup>
const props = defineProps({ const props = defineProps({
@@ -18,6 +21,10 @@ const props = defineProps({
size: { size: {
type: String, type: String,
default: null default: null
},
active: {
type: Boolean,
default: false
} }
}); });
@@ -33,6 +40,20 @@ const props = defineProps({
transition: filter 0.3s ease; transition: filter 0.3s ease;
} }
.icon-action {
position: relative;
}
.active-indicator {
position: absolute;
border-radius: 100%;
width: 4px;
height: 4px;
bottom: -30%;
left: 50%;
transform: translate(-50%, 0);
}
.icon:hover { .icon:hover {
cursor: pointer; cursor: pointer;
filter: brightness(0.8); filter: brightness(0.8);

View File

@@ -0,0 +1,129 @@
<template>
<div ref="container" class="marquee-container">
<div ref="scroller" class="marquee-scroller" :style="scrollerStyle">
<div ref="content" class="marquee-content">
<slot></slot>
</div>
<div v-if="shouldScroll" class="marquee-content">
<slot></slot>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from "vue";
const props = defineProps({
speed: { type: Number, default: 50 }, // px/s
delay: { type: Number, default: 3000 }, // délai initial et pause entre chaque loop
});
const container = ref(null);
const content = ref(null);
const scroller = ref(null);
const distance = ref(0);
const shouldScroll = ref(false);
const scrollerStyle = ref({ transform: "translateX(0)" });
let resizeObserver = null;
let mutationObserver = null;
let animationTimeout = null;
watch(shouldScroll, (newValue) => {
if (newValue) {
startAnimation();
} else {
stopAnimation()
}
});
function recalc() {
if (!container.value || !content.value) return;
const containerWidth = container.value.offsetWidth;
const contentWidth = content.value.scrollWidth;
if (contentWidth > containerWidth) {
distance.value = contentWidth;
shouldScroll.value = true;
startAnimation();
} else {
shouldScroll.value = false;
scrollerStyle.value.transform = "translateX(0)";
}
}
function startAnimation() {
if (!shouldScroll.value) return;
const duration = distance.value / props.speed * 1000; // ms
scrollerStyle.value.transition = "none";
scrollerStyle.value.transform = "translateX(0)";
// petite pause avant le début
setTimeout(() => {
if (!shouldScroll.value) return;
scrollerStyle.value.transition = `transform ${duration}ms linear`;
scrollerStyle.value.transform = `translateX(-${distance.value}px)`;
// après la fin, rappeler la fonction avec pause de 5s
animationTimeout = setTimeout(() => {
startAnimation();
}, duration + props.delay);
}, props.delay);
}
function stopAnimation() {
if (animationTimeout) {
clearTimeout(animationTimeout);
animationTimeout = null;
recalc()
scrollerStyle.value.transition = "none";
scrollerStyle.value.transform = "translateX(0)";
}
}
onMounted(() => {
recalc();
// observer taille
resizeObserver = new ResizeObserver(() => recalc());
resizeObserver.observe(container.value);
resizeObserver.observe(content.value);
// observer slot
mutationObserver = new MutationObserver(() => {
nextTick(() => recalc());
});
mutationObserver.observe(content.value, {
childList: true,
characterData: true,
subtree: true,
});
});
onBeforeUnmount(() => {
if (resizeObserver) resizeObserver.disconnect();
if (mutationObserver) mutationObserver.disconnect();
clearTimeout(animationTimeout);
});
</script>
<style scoped>
.marquee-container {
overflow: hidden;
position: relative;
width: 100%;
}
.marquee-scroller {
display: flex;
flex-direction: row;
}
.marquee-content {
flex-shrink: 0;
padding-right: 2rem;
}
</style>

View File

@@ -0,0 +1,102 @@
<template>
<div class="metric">
<div class="metric-header">
<Icon class="metric-icon" :icon="getIcons(metric.name)" />
<div class="metric-info">
<p class="metric-name">{{ metric.description.replace(server.id, "").replace(server.name, "").replace(":", "") }}</p>
<p class="metric-id">{{ metric.name }}</p>
</div>
</div>
<p class="metric-value" v-if="metric.name.includes('Seconds')">{{ getReadableDuration(metric.value) }}</p>
<p v-else class="metric-value">{{ metric.value }}</p>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { getReadableDuration } from '@/utils/TimeConverter';
const props = defineProps({
metric: {
type: Object,
required: true
},
server: {
type: Object,
required: true
}
});
function getIcons(name) {
if(name.includes("Commands")) return "fa-solid fa-terminal";
if(name.includes("Music")) return "fa-solid fa-music";
if(name.includes("Seconds")) return "fa-solid fa-clock";
return "fa-solid fa-chart-bar";
}
</script>
<style scoped>
.metric {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background-color: var(--tertiary);
border-radius: 10px;
}
.metric-info {
display: flex;
flex-direction: column;
}
.metric-info p {
margin: 0;
}
@media screen and (max-width: 768px) {
.metric {
flex-direction: column;
}
.metric-value {
width: 20%;
text-align: center;
margin-top: 5px;
}
.metric-name {
word-break: keep-all;
}
}
.metric-name {
font-weight: bold;
color: var(--text-primary);
}
.metric-id {
color: var(--text-secondary);
font-size: 0.7em;
word-break: break-all;;
}
.metric-header {
display: flex;
align-items: center;
gap: 10px;
}
.metric-value {
background-color: var(--secondary);
padding: 5px;
border-radius: 5px;
}
.metric-icon {
background-color: var(--secondary);
padding: 20px;
border-radius: 100%;
width: 16px;
height: 16px;
}
</style>

View File

@@ -28,16 +28,16 @@ const modalContent = ref(null);
// Close the modal when clicking outside of it // Close the modal when clicking outside of it
onMounted(() => { onMounted(() => {
document.addEventListener('click', handleClickOutside); document.addEventListener('pointerdown', handleClickOutside);
}); });
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('click', handleClickOutside); document.removeEventListener('pointerdown', handleClickOutside);
}); });
// Only clicking modal-overlay but not modal-content will close the modal // Only clicking modal-overlay but not modal-content will close the modal
function handleClickOutside(event) { function handleClickOutside(event) {
if (event.target === modal.value && modalContent.value && !modalContent.value.contains(event.target)) { if (event.pointerType === "mouse" && event.target === modal.value && modalContent.value && !modalContent.value.contains(event.target)) {
close(); close();
} }
} }
@@ -80,6 +80,8 @@ defineExpose({
color: var(--text-primary); color: var(--text-primary);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow-y: auto;
max-height: 75vh;
gap: 10px; gap: 10px;
} }
@@ -121,10 +123,8 @@ defineExpose({
} }
.modal p { .modal p {
margin: 0px; margin: 0px;
font-size: 1.3em;
font-weight: 600; font-weight: 600;
font-size: 1.2em;
font-family: 'Gunship', sans-serif;
font-weight: 500;
} }
.modal-overlay { .modal-overlay {
@@ -138,7 +138,7 @@ defineExpose({
display: none; display: none;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
z-index: 1500; z-index: 3500;
} }

View File

@@ -0,0 +1,57 @@
<template>
<div class="animation">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
</template>
<style scoped>
/* Import Google font - Poppins */
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@200;300;400;500;600;700&display=swap');
.animation{
height: 65px;
display: flex;
transform: rotate(180deg);
}
.animation span{
width: 30px;
margin: 0 2px;
border-radius: 6px;
animation: loader 3.5s infinite;
}
@keyframes loader{
0%, 100%{
height: 5px;
background: var(--main);
}
25%{
height: 65px;
background: var(--main-hover);
}
50%{
height: 30px;
background: var(--main-active);
}
75%{
height: 65px;
background: var(--main-hover);
}
}
.animation span:nth-child(5){
animation-delay: .2s;
}
.animation span:nth-child(4){
animation-delay: .4s;
}
.animation span:nth-child(3){
animation-delay: .6s;
}
.animation span:nth-child(2){
animation-delay: .8s;
}
.animation span:nth-child(1){
animation-delay: 1s;
}
</style>

View File

@@ -0,0 +1,66 @@
<template>
<div class="playlist-item">
<Icon :class="{'p-icon': true, 'selected': selected}" :icon="type === 'youtube' ? 'fa-brands fa-youtube' : 'fas fa-music'" />
<p>{{ title }}</p>
</div>
</template>
<script setup>
const props = defineProps({
title: {
type: String,
required: true,
default: 'My Playlist'
},
type: {
type: String,
required: true
},
selected: {
type: Boolean,
default: false
}
});
</script>
<style scoped>
.playlist-item {
display: flex;
align-items: center;
transition: 0.2s;
gap: 10px;
cursor: pointer;
}
.playlist-item:hover .p-icon {
background-color: var(--text);
color: var(--text-inverse);
}
.playlist-item p {
margin: 0;
font-size: 18px;
}
.p-icon {
display: flex;
align-items: center;
background-color: transparent;
padding: 15px;
border: 2px solid var(--text);
border-radius: 10px;
font-size: 25px;
transition: 0.2s;
}
.p-icon.selected {
background-color: var(--text);
color: var(--text-inverse);
}
.p-icon {
width: 25px;
height: 25px;
}
</style>

View File

@@ -7,7 +7,18 @@ const slots = useSlots();
const boxWidth = ref(0); const boxWidth = ref(0);
const box = ref(null); const box = ref(null);
const allSlots = slots.default ? slots.default() : []; var slotsAll = slots.default ? slots.default() : [];
var allSlots = null
if(slotsAll.length > 0) {
if(typeof slotsAll[0].children !== "string") {
allSlots = slotsAll[0].children
} else {
allSlots = slotsAll
}
}
var firstSlot = allSlots[0] || null; var firstSlot = allSlots[0] || null;
var otherSlots = allSlots.slice(1); var otherSlots = allSlots.slice(1);
@@ -23,7 +34,7 @@ function selectOption(index) {
otherSlots.push(oldFirstSlot); otherSlots.push(oldFirstSlot);
showMenu.value = false; // Hide the menu after selection showMenu.value = false; // Hide the menu after selection
} }
updateModelValue(firstSlot ? firstSlot.props.value : ''); updateModelValue(firstSlot ? firstSlot.props ? firstSlot.props.value : '' : '');
} }
const props = defineProps({ const props = defineProps({
@@ -50,8 +61,7 @@ onMounted(() => {
showMenu.value = false; showMenu.value = false;
}); });
updateModelValue(firstSlot ? firstSlot.props ? firstSlot.props.value : '' : '');
updateModelValue(firstSlot ? firstSlot.props.value : '');
}); });
@@ -131,6 +141,7 @@ defineExpose({
user-select: none; user-select: none;
overflow-y: auto; overflow-y: auto;
max-height: 16vh; max-height: 16vh;
z-index: 4000;
} }
.container .option:last-child { .container .option:last-child {

View File

@@ -30,10 +30,10 @@ const props = defineProps({
.container-avatar { .container-avatar {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px;; gap: 10px;
width: 100%;
} }
p { p {
margin: 0; margin: 0;
} }

View File

@@ -4,13 +4,61 @@
<LogoWhite class="img"/> <LogoWhite class="img"/>
<h1>Subsonics</h1> <h1>Subsonics</h1>
</div> </div>
<slot></slot> <span class="high"><slot></slot></span>
<span v-for="(note, index) in notes"
:key="index"
class="note delayed"
:style="{ left: `${note.x}%`, top: `${note.y}%`, '--i': index }"
>
{{ note.value }}
</span>
</div> </div>
</template> </template>
<script setup> <script setup>
import LogoWhite from '@/assets/LogoWhite.vue'; import LogoWhite from '@/assets/LogoWhite.vue';
import { computed, ref } from 'vue';
var baseNote = ["♪", "♫", "♬", "♩", "♭", "♯"];
var notesCount = computed(() => {
// Depends on the width with min of 5
return Math.max(5, Math.floor(window.innerWidth / 100));
});
var notes = ref([]);
// fonction pour générer des positions non chevauchantes
function generateNotes() {
let positions = [];
notes.value = []; // réinitialiser les notes
for (let i = 0; i < notesCount.value; i++) {
let x, y;
let safe = false;
// essaie jusqu'à trouver une position correcte
while (!safe) {
x = Math.random() * 100;
y = Math.random() * 100;
safe = true;
for (let pos of positions) {
if (Math.abs(pos.x - x) < 10 && Math.abs(pos.y - y) < 10) {
safe = false; // trop proche, rejeter
break;
}
}
}
positions.push({x, y});
notes.value.push({
value: baseNote[i % baseNote.length],
x: x,
y: y
});
}
}
generateNotes();
</script> </script>
<style scoped> <style scoped>
@@ -19,10 +67,19 @@ import LogoWhite from '@/assets/LogoWhite.vue';
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
position: relative;
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
gap: 46px; gap: 46px;
background-color: var(--main); /* Example background color */ background: linear-gradient(var(--main-active),var(--main), var(--main), var(--main-active));
overflow: hidden;
}
.high {
z-index: 100;
width: 100%;
display: flex;
justify-content: center;
} }
h1 { h1 {
@@ -43,4 +100,31 @@ import LogoWhite from '@/assets/LogoWhite.vue';
display: flex; display: flex;
align-items: center; align-items: center;
} }
.note {
position: absolute;
font-size: 75px;
color: rgba(255, 255, 255, 0.2);
animation: float 10s infinite linear;
opacity: 0;
}
.delayed {
animation-delay: calc(var(--i) * 0.2s);
}
@keyframes float {
from {
transform: translateY(100%) rotate(90deg);
opacity: 0;
}
20% {
opacity: 1;
}
to {
transform: translateY(-10vh) rotate(360deg);
opacity: 0;
}
}
</style> </style>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div> <div class="subsonics-logo">
<LogoDark class="img" v-if="globalStore.theme == 'light'"/> <LogoDark class="img" v-if="globalStore.theme == 'light'"/>
<LogoLight class="img" v-else/> <LogoLight class="img" v-else/>
<h1>Subsonics</h1> <h1>Subsonics</h1>
@@ -20,6 +20,22 @@ const globalStore = useGlobalStore();
div { div {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px;
} }
.subsonics-logo {
width: 100%;
justify-content: center;
}
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
.img {
width: 70px;
height: 70px;
}
h1 {
font-size: 6vw;
}
}
</style> </style>

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="tag" :style="{ borderColor: props.color }"> <div class="tag" :style="{ borderColor: props.color }">
<Icon :color="props.color" icon="fa-circle fa-solid"/> <Icon :color="props.color" :icon="props.icon"/>
<p><slot></slot></p> <p><slot></slot></p>
</div> </div>
</template> </template>
@@ -9,6 +9,10 @@
color: { color: {
type: String, type: String,
default: '' default: ''
},
icon: {
type: String,
default: 'fa-circle fa-solid'
} }
}); });
</script> </script>
@@ -16,12 +20,13 @@
.tag { .tag {
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 20px; border-radius: 20px;
padding: 5px; padding: 5px 6px;
margin: 0; margin: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 5px; gap: 5px;
font-size: 0.7em; font-size: 0.7em;
width: fit-content;
} }
p { p {

View File

@@ -1,6 +1,6 @@
<template> <template>
<div class="user-card"> <div :class="{'user-card': !mobile, 'mobile': mobile}">
<Avatar :avatar-url="user?.identity?.avatar" :user-id="user?.identity?.id" <Avatar :mobile="mobile" :avatar-url="user?.identity?.avatar" :user-id="user?.identity?.id"
:isMod="user?.identity?.isMod" :isMod="user?.identity?.isMod"
:isOwner="user?.identity?.isOwner" :isOwner="user?.identity?.isOwner"
:isAdmin="user?.identity?.isAdmin"/> :isAdmin="user?.identity?.isAdmin"/>
@@ -23,6 +23,10 @@ const props = defineProps({
avatar: '96ecebdefe9fb9483b2e1b17a417ba32' avatar: '96ecebdefe9fb9483b2e1b17a417ba32'
} }
}) })
},
mobile: {
type: Boolean,
default: false
} }
}); });
@@ -45,6 +49,31 @@ p {
user-select: none; user-select: none;
} }
.mobile {
display: flex;
align-items: center;
flex-direction: column;
gap: 8px;
user-select: none;
}
.mobile .user-info {
display: flex;
justify-content: center;
align-items: center;
}
.mobile .username {
font-size: 1.2em !important;
}
.mobile .global {
font-size: 1.6em !important;
}
.user-info { .user-info {
display: flex; display: flex;
gap: 5px ; gap: 5px ;

172
src/components/UI/Video.vue Normal file
View File

@@ -0,0 +1,172 @@
<template>
<div ref="videoContainer" :class="{'video': true, 'video-mobile': mobile}">
<div ref="thumbnailContainer" :class="{'thumbnail-container': true, 'thumbnail-mobile': mobile}">
<img v-if="video" class="thumbnail" :src="video.thumbnail" alt="Video Thumbnail" />
<div v-else class="thumbnail-placeholder"></div>
<p v-if="video && typeof video.duration === 'number' && video.duration != 0 && !mobile" class="duration">{{ getVideoDuration(video.duration) }}</p>
<p v-else-if="video && video.duration === 0 && !mobile" class="duration align"><Icon font-size="10px" color="red" icon="fa-solid fa-circle"></Icon> LIVE</p>
<span v-else></span>
<slot></slot>
</div>
<div :class="{'info-mobile': mobile, 'info': !mobile}">
<Marquee v-if="video && mobile"><p>{{ video.title }}</p></Marquee>
<p v-else-if="video" class="title">{{ video.title }}</p>
<p v-else class="title">Aucun titre jouée</p>
<p v-if="video" class="author">{{ video.author }}</p>
<p v-else class="author">Aucun auteur</p>
</div>
</div>
</template>
<script setup>
import { getVideoDuration } from '@/utils/TimeConverter';
import { ref } from 'vue';
import Marquee from './Marquee.vue';
const props = defineProps({
video: {
required: true,
default: null
},
mobile: {
type: Boolean,
default: false
}
});
const thumbnailContainer = ref(null);
const videoContainer = ref(null);
function getThumbnailContainer() {
return thumbnailContainer.value;
}
function getVideoContainer() {
return videoContainer.value;
}
defineExpose({
getThumbnailContainer,
getVideoContainer,
});
</script>
<style scoped>
.align {
display: flex;
align-items: center;
gap: 3px;
}
.thumbnail {
width: 100%;
height: auto;
border-radius: 10px;
user-select: none;
aspect-ratio: 16/9;
object-fit: cover;
background-color: var(--tertiary);
}
.thumbnail-container {
position: relative;
display: flex;
width: 100%;
}
.thumbnail-mobile {
width: 100% !important;
margin-left: auto;
margin-right: auto;
max-width: 350px;
}
.thumbnail-placeholder {
flex: 1;
height: auto;
border-radius: 10px;
user-select: none;
aspect-ratio: 16/9;
object-fit: cover;
background-color: var(--quaternary);
}
.duration {
position: absolute;
bottom: 0px;
right: 0px;
background-color: rgba(0, 0, 0, 0.5);
color: white;
padding: 2px 5px;
border-radius: 15px;
font-size: 0.8em;
margin: 3% 0.5% 3% 3%;
}
.info {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
gap: 5px;
overflow-wrap: break-word;
word-break: break-word;
white-space: normal;
text-align: center;
}
.info p {
margin: 0;
font-size: 0.9em;
}
.title {
font-weight: bold;
margin: 5px 0 0 0;
}
.author {
color: var(--text-secondary);
}
.video {
display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
justify-content: space-between;
gap: 5px;
}
.video-mobile {
display: flex;
flex-direction: column;
align-items: start !important;
cursor: pointer;
justify-content: space-between;
gap: 10px;
margin-left: 10px;
margin-right: 10px;
width: 100%;
}
.info-mobile {
display: flex;
flex-direction: column;
gap: 5px;
width: 100%;
}
.info-mobile p {
margin: 0;
font-size: 1.3em;
}
</style>

View File

@@ -0,0 +1,142 @@
<template>
<div :data-theme="mobile ? 'dark' : null" :class="'video'" >
<div :class="mobile ? 'mobile-thumbnail-container' : 'thumbnail-container'">
<img v-if="video && video.thumbnail && !playerOffline" :class="mobile ? 'mobile-thumbnail' : 'thumbnail'" :src="video.thumbnail" alt="Video Thumbnail" />
<div v-else :class="mobile ? 'mobile-thumbnail-placeholder' : 'thumbnail-placeholder'"></div>
</div>
<div class="info">
<p v-if="playerOffline" class="title secondary">Subsonics est hors-ligne</p>
<Marquee v-else-if="video" :class="{'title': true}">
<span :style="{ color: mobile ? 'white' : '' }">{{ video.title }}</span>
</Marquee>
<p v-else class="title">Aucun titre jouée</p>
<p v-if="playerOffline" class="author">Connectez vous d'abord à un salon audio pour lancer un titre</p>
<p v-else-if="video" class="author">{{ video.author }}</p>
<p v-else class="author">Aucun auteur</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from "vue";
import Marquee from "./Marquee.vue";
const props = defineProps({
video: {
required: true,
default: null
},
playerOffline: {
type: Boolean,
default: false
},
mobile: {
type: Boolean,
default: false
}
});
</script>
<style scoped>
.thumbnail {
width: 100%;
height: auto;
border-radius: 10px;
user-select: none;
aspect-ratio: 16/9;
object-fit: cover;
background-color: var(--tertiary);
}
.mobile-thumbnail {
width: 100%;
height: auto;
border-radius: 10px;
user-select: none;
aspect-ratio: 1/1;
object-fit: cover;
background-color: var(--tertiary);
}
.thumbnail-placeholder {
width: 100%;
height: 100%;
background-color: var(--tertiary);
border-radius: 10px;
aspect-ratio: 16/9;
}
.thumbnail-container {
position: relative;
display: flex;
width: 12%;
min-width: 100px;
}
.mobile-thumbnail-container {
position: relative;
width: 12%;
min-width: 40px;
}
.mobile-thumbnail-placeholder {
width: 100%;
height: 100%;
background-color: var(--tertiary);
border-radius: 10px;
aspect-ratio: 1/1;
}
.duration {
position: absolute;
bottom: 0px;
right: 0px;
background-color: rgba(0, 0, 0, 0.5);
color: white;
padding: 2px 5px;
border-radius: 15px;
font-size: 0.8em;
margin: 3% 0.5% 3% 3%;
}
.info {
display: flex;
flex-direction: column;
flex: 1;
gap: 5px;
overflow-wrap: break-word;
word-break: break-word;
white-space: normal;
width: 1px;
}
.info p {
margin: 0;
}
.title {
font-weight: bold;
}
.secondary {
opacity: 0.8;
}
.author {
color: var(--text-secondary);
font-size: 0.8em !important;
}
.video {
display: flex;
align-items: center;
cursor: pointer;
justify-content: space-between;
gap: 10px;
width: 100%;
}
</style>

View File

@@ -0,0 +1,144 @@
<template>
<div :data-theme="mobile ? 'dark' : null" :class="'video'" >
<div :class="mobile ? 'mobile-thumbnail-container' : 'thumbnail-container'">
<div v-if="playerOffline" :class="mobile ? 'mobile-thumbnail-placeholder' : 'thumbnail-placeholder'"></div>
<img v-else-if="video && video.thumbnail" :class="mobile ? 'mobile-thumbnail' : 'thumbnail'" :src="video.thumbnail" alt="Video Thumbnail" />
<div v-else :class="mobile ? 'mobile-thumbnail-placeholder' : 'thumbnail-placeholder'"></div>
</div>
<div class="info">
<p v-if="playerOffline" class="title secondary">Subsonics est hors-ligne</p>
<Marquee v-else-if="video" :class="{'title': true}">
<span :style="{ color: mobile ? 'white' : '' }">{{ video.title }}</span>
</Marquee>
<p v-else class="title">Aucun titre jouée</p>
<p v-if="playerOffline" class="author">Connectez vous d'abord à un salon audio pour lancer un titre</p>
<p v-else-if="video" class="author">{{ video.author }}</p>
<p v-else class="author">Aucun auteur</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from "vue";
import Marquee from "./Marquee.vue";
const props = defineProps({
video: {
required: true,
default: null
},
playerOffline: {
type: Boolean,
default: false
},
mobile: {
type: Boolean,
default: false
}
});
</script>
<style scoped>
.thumbnail {
width: 100%;
height: auto;
border-radius: 10px;
user-select: none;
aspect-ratio: 16/9;
object-fit: cover;
background-color: var(--tertiary);
}
.mobile-thumbnail {
width: 100%;
height: auto;
border-radius: 10px;
user-select: none;
aspect-ratio: 1/1;
object-fit: cover;
background-color: var(--tertiary);
}
.thumbnail-placeholder {
width: 100%;
height: 100%;
background-color: var(--tertiary);
border-radius: 10px;
aspect-ratio: 16/9;
}
.thumbnail-container {
position: relative;
width: 10%;
min-width: 70px;
}
.mobile-thumbnail-container {
position: relative;
width: 12%;
min-width: 40px;
}
.mobile-thumbnail-placeholder {
width: 100%;
height: 100%;
background-color: var(--tertiary);
border-radius: 10px;
aspect-ratio: 1/1;
}
.duration {
position: absolute;
bottom: 0px;
right: 0px;
background-color: rgba(0, 0, 0, 0.5);
color: white;
padding: 2px 5px;
border-radius: 15px;
font-size: 0.8em;
margin: 3% 0.5% 3% 3%;
}
.info {
display: flex;
flex-direction: column;
flex: 1;
gap: 5px;
overflow-wrap: break-word;
word-break: break-word;
white-space: normal;
width: 1px;
font-size: 0.8em !important;
}
.info p {
margin: 0;
}
.title {
font-weight: bold;
}
.secondary {
opacity: 0.8;
}
.author {
color: var(--text-secondary);
font-size: 0.8em !important;
}
.video {
display: flex;
align-items: center;
cursor: pointer;
justify-content: space-between;
gap: 10px;
width: 100%;
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<Box class="box" padding="closed">
<Carousel only-mobile :auto-play="false" disable-arrow class="carousel">
<Playlist/>
<Queue class="dispatch-mobile"/>
<AccountMobile mobile class="account"/>
</Carousel>
</Box>
</template>
<script setup>
import Box from '@/components/UI/Box.vue';
import Carousel from '@/components/UI/Carousel.vue';
import Playlist from '@/components/Layout/Playlist.vue';
import Queue from '@/components/Layout/Queue.vue';
import AccountMobile from '@/components/Widget/View/Home/AccountMobile.vue';
</script>
<style scoped>
.carousel {
flex: 1;
}
.box {
display: flex;
flex: 1;
padding: 15px;
}
@media screen and (max-width: 1280px), screen and (max-height: 607px) {
.box {
padding-bottom: 5px;
}
}
</style>

View File

@@ -60,7 +60,7 @@ function banUser() {
IORequest("/MOD/USERS/BAN", () => { IORequest("/MOD/USERS/BAN", () => {
console.log("User banned:", targetUser.value.id); console.log("User banned:", targetUser.value.id);
}, targetUser.value.id); }, targetUser.value.id);
//TODO: CHECK THE userListConnected which have sameUser
} }
</script> </script>
<style scoped> <style scoped>

View File

@@ -19,6 +19,10 @@ import Events from '@/utils/Events';
const modal = ref(null); const modal = ref(null);
//TODO: Ajouter la sécurité des roles pour empêcher l'utilisation publique du Bot
//TODO: Ajout de Log pour serveur
//TODO: Paramétérer une liste des channels autorisé !
const props = defineProps({ const props = defineProps({
server: { server: {
type: Object, type: Object,

View File

@@ -1,16 +1,6 @@
<template> <template>
<div class="metrics" v-if="metrics && metrics.length > 0"> <div class="metrics" v-if="metrics && metrics.length > 0">
<div class="metric" v-for="metric in metrics" :key="metric.name"> <Metric v-for="metric in metrics" :key="metric.id" :metric="metric" :server="server"/>
<div class="metric-header">
<Icon class="metric-icon" :icon="getIcons(metric.name)" />
<div class="metric-info">
<p class="metric-name">{{ metric.description.replace(server.id, "").replace(server.name, "").replace(":", "") }}</p>
<p class="metric-id">{{ metric.name }}</p>
</div>
</div>
<p class="metric-value" v-if="metric.name.includes('Seconds')">{{ getReadableDuration(metric.value) }}</p>
<p v-else class="metric-value">{{ metric.value }}</p>
</div>
</div> </div>
<div v-else> <div v-else>
<p class="second">Aucune statistique enregistrée !</p> <p class="second">Aucune statistique enregistrée !</p>
@@ -22,13 +12,9 @@ import Events from '@/utils/Events';
import { ref } from 'vue'; import { ref } from 'vue';
import { useUserStore } from '@/stores/userStore'; import { useUserStore } from '@/stores/userStore';
import { getReadableDuration } from '@/utils/TimeConverter'; import { getReadableDuration } from '@/utils/TimeConverter';
import Metric from '@/components/UI/Metric.vue';
function getIcons(name) {
if(name.includes("Commands")) return "fa-solid fa-terminal";
if(name.includes("Music")) return "fa-solid fa-music";
if(name.includes("Seconds")) return "fa-solid fa-clock";
return "fa-solid fa-chart-bar";
}
const userStore = useUserStore(); const userStore = useUserStore();
@@ -56,15 +42,6 @@ Events.on("GUILD_JOINED", () => {
font-size: 0.8em; font-size: 0.8em;
} }
.metric {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background-color: var(--tertiary);
border-radius: 10px;
}
.metrics { .metrics {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -74,55 +51,4 @@ Events.on("GUILD_JOINED", () => {
max-height: 30vh; max-height: 30vh;
} }
.metric-info {
display: flex;
flex-direction: column;
}
.metric-info p {
margin: 0;
}
@media screen and (max-width: 768px) {
.metric {
flex-direction: column;
}
.metric-name {
word-break: break-all;
}
}
.metric-name {
font-weight: bold;
color: var(--text-primary);
}
.metric-id {
color: var(--text-secondary);
font-size: 0.7em;
word-break: break-all;;
}
.metric-header {
display: flex;
align-items: center;
gap: 10px;
}
.metric-value {
background-color: var(--secondary);
padding: 5px;
border-radius: 5px;
}
.metric-icon {
background-color: var(--secondary);
padding: 20px;
border-radius: 100%;
width: 16px;
height: 16px;
}
</style> </style>

View File

@@ -0,0 +1,203 @@
<template>
<section class="list-global">
<Button :disabled="!online || !nextList || nextList.length === 0" @click="clearList()" icon="fa-trash">Vider la liste</Button>
<section class="list-container">
<draggable v-model="localNextList" v-bind="dragOptions" @end="onDragEnd" item-key="id" :disabled="!online" :class="{ 'drag-disabled': !online }">
<template #item="{ element: video, index }">
<div class="list-video">
<VideoQueue little :video="video"></VideoQueue>
<IconAction @click="setSelectedIndex(index, $event)" icon="fa-ellipsis-vertical"></IconAction>
</div>
</template>
</draggable>
<div class="allSpace" v-if="online && (!nextList || nextList.length === 0)">
<p class="text-secondary"><Icon font-size="2em" icon="fa-solid fa-circle-xmark"/> Aucun élément dans la liste de lecture</p>
</div>
<div class="allSpace" v-else-if="!online">
<p class="text-secondary"><Icon font-size="2em" icon="fa-solid fa-link-slash"/> Subsonics est hors ligne</p>
</div>
<ContextMenu ref="contextMenu">
<div @click="playNow()">
<Icon icon="fa-solid fa-play"/>
<p>Jouer maintenant</p>
</div>
<div @click="moveUp()">
<Icon icon="fa-solid fa-list-ol"/>
<p>Déplacer en première position</p>
</div>
<div @click="savePlaylist()">
<Icon icon="fa-solid fa-floppy-disk"/>
<p>Sauvegarder dans une playlist</p>
</div>
<div @click="deleteItem()">
<Icon icon="fa-solid fa-trash"/>
<p>Supprimer</p>
</div>
</ContextMenu>
</section>
<Modal ref="saveModal" icon="fa-save" title="Sauvegarder dans une playlist">
<Video v-if="selectedIndex" class="save-video" :video="nextList[selectedIndex]"/>
<Selector ref="playlistSelector" v-if="userStore.playlists?.length > 0">
<p class="selectorp" :value="playlist.playlistId" v-for="playlist in userStore.playlists" :key="playlist.playlistId"><Icon :icon="playlist.type === 'youtube' ? 'fa-brands fa-youtube' : 'fa-solid fa-music'" /> {{ playlist.title }}</p>
</Selector>
<p v-else class="info-no">Vous n'avez pas encore de playlist. Créez-en une pour sauvegarder ce titre.</p>
<Button :disabled="userStore.playlists?.length === 0" @click="Events.emit('video:add', { video: nextList[selectedIndex], playlistId: playlistSelector.firstSlot().props.value }); saveModal.close()"><Icon icon="fa-solid fa-save" /> Sauvegarder</Button>
</Modal>
</section>
</template>
<script setup>
import VideoQueue from '@/components/UI/VideoQueue.vue';
import IconAction from '@/components/UI/IconAction.vue';
import { useId, ref, onMounted, watch } from 'vue';
import ContextMenu from '@/components/UI/ContextMenu.vue';
import Button from '@/components/UI/Button.vue';
import { IORequest } from '@/utils/IORequest';
import Modal from '@/components/UI/Modal.vue';
import Selector from '@/components/UI/Selector.vue';
import Video from '@/components/UI/Video.vue';
import { useUserStore } from '@/stores/userStore';
import Events from '@/utils/Events';
import draggable from 'vuedraggable';
const id = useId()
const contextMenu = ref(null)
const selectedIndex = ref(null)
const saveModal = ref(null)
const userStore = useUserStore()
const playlistSelector = ref(null)
const localNextList = ref([])
const dragOptions = {
animation: 200,
disabled: false,
ghostClass: 'ghost',
handle: '.list-video',
forceFallback: false,
fallbackClass: 'dragging',
dragClass: 'dragging',
fallbackOnBody: false,
swapThreshold: 0.65,
};
const props = defineProps({
selectedTab: Number,
online: {},
nextList: Array
})
watch(() => props.nextList, (newList) => {
localNextList.value = newList
})
onMounted(() => {
localNextList.value = props.nextList
})
const emit = defineEmits(['update:nextList']);
function clearList() {
IORequest("/QUEUE/NEXT/DELETEALL")
}
function setSelectedIndex(index, event) {
selectedIndex.value = index;
// Check if the ctrl key is pressed without using event
const isCtrlPressed = event.ctrlKey || event.metaKey;
if(isCtrlPressed) {
deleteItem()
} else {
contextMenu.value.show()
}
}
function deleteItem() {
if(selectedIndex.value == null) return;
IORequest("/QUEUE/NEXT/DELETE", null, selectedIndex.value)
}
function moveUp() {
if(selectedIndex.value == null) return;
IORequest("/QUEUE/NEXT/MOVE", null, {index: selectedIndex.value, newIndex: 0} )
}
function playNow() {
if(selectedIndex.value == null) return;
IORequest("/QUEUE/PLAY", null, {index: selectedIndex.value, now: true, listType: "next"} )
}
function savePlaylist() {
if(selectedIndex.value == null) return;
saveModal.value.open()
}
function onDragEnd(event) {
if (!props.online) return;
const oldIndex = event.oldIndex;
const newIndex = event.newIndex;
if (oldIndex !== newIndex) {
IORequest("/QUEUE/NEXT/MOVE", null, { index: oldIndex, newIndex: newIndex });
}
}
</script>
<style scoped>
.list-container {
flex: 1;
display: flex;
flex-direction: column;
gap: 10px;
padding-right: 5px;
width: 100%;
height: 100%;
overflow-y: auto;
user-select: none;
}
.dragging {
opacity: 0;
display: none;
}
.list-global {
flex: 1;
display: flex;
position: absolute;
flex-direction: column;
gap: 10px;
overflow: hidden;
padding-right: 5px;
width: 100%;
height: 100%;
}
.text-secondary {
color: var(--text-secondary);
display: flex;
align-items: center;
flex-direction: column;
flex: 1;
gap: 10px;
justify-content: center;
font-size: 1.3em;
text-align: center;
}
.list-video {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
margin-top: 2.5px;
margin-bottom: 2.5px;
}
.allSpace {
flex: 1;
display: flex;
}
</style>

View File

@@ -0,0 +1,120 @@
<template>
<section class="list-container">
<div v-if="previousList && previousList.length > 0" v-for="(video, index) in previousList" :key="id" class="list-video">
<VideoQueue little :video="video" :index="index"></VideoQueue>
<IconAction @click="setSelectedIndex(index)" icon="fa-ellipsis-vertical"></IconAction>
</div>
<div class="allSpace" v-else>
<p class="text-secondary"><Icon font-size="2em" icon="fa-solid fa-circle-xmark"/> Aucun élément dans l'historique</p>
</div>
<ContextMenu ref="contextMenu">
<div v-if="globalStore.currentChannel" @click="playNow(false)">
<AddList/>
<p>Ajouter à la liste de lecture</p>
</div>
<div v-if="globalStore.currentChannel" @click="playNow(true)">
<Icon icon="fa-solid fa-play"/>
<p>Jouer maintenant</p>
</div>
<div @click="savePlaylist()">
<Icon icon="fa-solid fa-floppy-disk"/>
<p>Sauvegarder dans une playlist</p>
</div>
</ContextMenu>
<Modal ref="saveModal" icon="fa-save" title="Sauvegarder dans une playlist">
<Video v-if="selectedIndex" class="save-video" :video="previousList[selectedIndex]"/>
<Selector ref="playlistSelector" v-if="userStore.playlists?.length > 0">
<p class="selectorp" :value="playlist.playlistId" v-for="playlist in userStore.playlists" :key="playlist.playlistId"><Icon :icon="playlist.type === 'youtube' ? 'fa-brands fa-youtube' : 'fa-solid fa-music'" /> {{ playlist.title }}</p>
</Selector>
<p v-else class="info-no">Vous n'avez pas encore de playlist. Créez-en une pour sauvegarder ce titre.</p>
<Button :disabled="userStore.playlists?.length === 0" @click="Events.emit('video:add', { video: previousList[selectedIndex], playlistId: playlistSelector.firstSlot().props.value }); saveModal.close()"><Icon icon="fa-solid fa-save" /> Sauvegarder</Button>
</Modal>
</section>
</template>
<script setup>
import VideoQueue from '@/components/UI/VideoQueue.vue';
import IconAction from '@/components/UI/IconAction.vue';
import { ref, onMounted, watch, useId } from 'vue';
import ContextMenu from '@/components/UI/ContextMenu.vue';
import Button from '@/components/UI/Button.vue';
import { IORequest } from '@/utils/IORequest';
import Modal from '@/components/UI/Modal.vue';
import Selector from '@/components/UI/Selector.vue';
import Video from '@/components/UI/Video.vue';
import { useUserStore } from '@/stores/userStore';
import Events from '@/utils/Events';
import AddList from '@/assets/Icons/AddList.vue';
import { useGlobalStore } from '@/stores/globalStore';
const id = useId()
const userStore = useUserStore();
const globalStore = useGlobalStore();
const selectedIndex = ref(null)
const contextMenu = ref(null)
const saveModal = ref(null)
const playlistSelector = ref(null)
function setSelectedIndex(index) {
selectedIndex.value = index;
contextMenu.value.show()
}
function playNow(now) {
if(selectedIndex.value == null) return;
IORequest("/QUEUE/PLAY", null, {index: selectedIndex.value, now: now, listType: "previous"} )
}
function savePlaylist() {
if(selectedIndex.value == null) return;
saveModal.value.open()
}
defineProps({
selectedTab: Number,
online: {},
previousList: Array
})
</script>
<style scoped>
.list-container {
flex: 1;
display: flex;
position: absolute;
flex-direction: column;
gap: 5px;
overflow-y: auto;
padding-right: 5px;
width: 100%;
height: 100%;
}
.text-secondary {
color: var(--text-secondary);
display: flex;
align-items: center;
flex-direction: column;
flex: 1;
gap: 10px;
justify-content: center;
font-size: 1.3em;
text-align: center;
}
.list-video {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.allSpace {
flex: 1;
display: flex;
}
</style>

View File

@@ -53,10 +53,12 @@ function access() {
gap: 10px; gap: 10px;
} }
@media screen and (max-width: 768px) { @media screen and (max-width: 768px),
screen and (max-height: 607px) {
.container-list { .container-list {
flex-direction: column; flex-direction: column;
gap: 20px;; gap: 20px;
} }

View File

@@ -11,7 +11,7 @@
</div> </div>
<div class="text"> <div class="text">
<p>Subsonics a été développé avec par Raphix et existe depuis le 9 avril 2023.</p> <p>Subsonics a été développé avec par Raphix et existe depuis le 9 avril 2023.</p>
<p> Merci énormément à tous le CLP en particulier : </p> <p> Merci énormément à tous le CLP en particulier :</p>
<ul> <ul>
<li>IcePlayer: Pour ses conseils, son "RAAAPHIX", sa confiance accordée et pour avoir créer le CLP</li> <li>IcePlayer: Pour ses conseils, son "RAAAPHIX", sa confiance accordée et pour avoir créer le CLP</li>
<li>Gabouille : Pour ses conseils, sa bonne humeur, pour le magnifique logo et pour m'avoir aider à redesigner le site.</li> <li>Gabouille : Pour ses conseils, sa bonne humeur, pour le magnifique logo et pour m'avoir aider à redesigner le site.</li>
@@ -72,6 +72,17 @@ onMounted(() => {
font-size: 12px; font-size: 12px;
color: var(--text-secondary); color: var(--text-secondary);
margin: 10px 0 0 0; margin: 10px 0 0 0;
display: flex;
gap: 4px;
}
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
.name {
flex-direction: column;
align-items: center;
}
} }
.info { .info {

View File

@@ -0,0 +1,246 @@
<template>
<Video :video="video" ref="videoContainer">
<div ref="controls" class="controls">
<span v-if="globalStore.currentChannel" title="Ajouter à la liste de lecture" @click="disableAction(); playSong(false)" class="control-icon"><AddList /></span>
<span v-if="globalStore.currentChannel" title="Lire maintenant" @click="disableAction(); playSong(true)" class="control-icon"><Icon icon="fa-play" /></span>
<span v-if="!props.delete && video.type != 'attachment'" title="Enregistrer dans une playlist" @click="disableAction(); saveModal.open()" class="control-icon"><Icon icon="fa-save" /></span>
<span v-if="props.delete" title="Supprimer" @click="disableAction(); Events.emit('video:delete', { video: props.video })" class="control-icon"><Icon icon="fa-trash" /></span>
</div>
</Video>
<Modal icon="fa-solid fa-video" title="Actions" ref="modal">
<Video :video="video"/>
<Button v-if="globalStore.currentChannel" @click="playSong(false)"><AddList /> Ajouter à la liste de lecture</Button>
<Button v-if="globalStore.currentChannel" @click="playSong(true)"><Icon icon="fa-solid fa-play"/> Lire maintenant</Button>
<div v-else>
<p class="text-secondary">Connectez vous à un salon audio sur le serveur {{ globalStore.actualServer ? globalStore.actualServer.name : '' }}, pour lancer un titre</p>
<ActualChannel/>
</div>
<Button v-if="!props.delete && video.type != 'attachment'" @click="saveModal.open()"><Icon icon="fa-solid fa-save" /> Enregistrer dans une playlist</Button>
<Button v-if="props.delete" @click="Events.emit('video:delete', { video: props.video })"><Icon icon="fa-solid fa-trash" /> Supprimer</Button>
</Modal>
<Modal ref="saveModal" icon="fa-save" title="Sauvegarder dans une playlist">
<Video class="save-video" :video="video"/>
<Selector ref="playlistSelector" v-if="userStore.playlists?.length > 0">
<p class="selectorp" :value="playlist.playlistId" v-for="playlist in userStore.playlists" :key="playlist.playlistId"><Icon :icon="playlist.type === 'youtube' ? 'fa-brands fa-youtube' : 'fa-solid fa-music'" /> {{ playlist.title }}</p>
</Selector>
<p v-else class="info-no">Vous n'avez pas encore de playlist. Créez-en une pour sauvegarder ce titre.</p>
<Button :disabled="userStore.playlists?.length === 0" @click="Events.emit('video:add', { video: props.video, playlistId: playlistSelector.firstSlot().props.value }); saveModal.close()"><Icon icon="fa-solid fa-save" /> Sauvegarder</Button>
</Modal>
</template>
<script setup>
import { onMounted, onBeforeUnmount, ref } from 'vue';
import Modal from '@/components/UI/Modal.vue';
import Button from '@/components/UI/Button.vue';
import Video from '@/components/UI/Video.vue';
import Events from '@/utils/Events';
import Selector from '../UI/Selector.vue';
import { useUserStore } from '@/stores/userStore';
import { IORequest } from '@/utils/IORequest';
import { useGlobalStore } from '@/stores/globalStore';
import ActualChannel from './View/Player/ActualChannel.vue';
import AddList from '@/assets/Icons/AddList.vue';
const controls = ref(null)
const thumbnailContainer = ref(null)
const videoContainer = ref(null)
const modal = ref(null)
const saveModal = ref(null)
const userStore = useUserStore();
const playlistSelector = ref(null)
const globalStore = useGlobalStore();
const props = defineProps({
video: {
type: Object,
required: true
},
delete: {
type: Boolean,
default: false
}
});
let nativeVideo = {}
function disableAction() {
controls.value.style.display = "none";
}
let touchStartX = 0;
let touchStartY = 0;
let isSliding = false;
let activePointerId = null;
const SLIDE_THRESHOLD = 10; // ajuster si besoin
function playSong(now) {
IORequest("/SEARCH/PLAY", (data) => {
modal.value.close();
}, {song: nativeVideo, now: now})
}
onMounted(() => {
Object.assign(nativeVideo, props.video);
if(props.video.createdAt) {
props.video.author = 'Ajoutée le ' + new Date(props.video.createdAt).toLocaleString()
props.video.thumbnail = '/src/assets/default_thumbnail.png';
nativeVideo.author = userStore.userInfo.identity.username;
}
if(!videoContainer.value) return
thumbnailContainer.value = videoContainer.value.getThumbnailContainer();
videoContainer.value = videoContainer.value.getVideoContainer();
thumbnailContainer.value.addEventListener('pointerenter', (ev) => {
if(!ev) return;
if (ev.pointerType === 'mouse') {
controls.value.style.display = "flex";
}
});
thumbnailContainer.value.addEventListener('pointerleave', (ev) => {
if(!ev) return;
if (ev.pointerType === 'mouse') {
controls.value.style.display = "none";
}
});
const onPointerMove = (ev) => {
if (ev.pointerId !== activePointerId) return;
const dx = Math.abs(ev.clientX - touchStartX);
const dy = Math.abs(ev.clientY - touchStartY);
if (dx > SLIDE_THRESHOLD || dy > SLIDE_THRESHOLD) {
isSliding = true;
}
};
const cleanupPointerListeners = () => {
try {
if (activePointerId != null) videoContainer.value.releasePointerCapture(activePointerId);
} catch (e) { /* ignore */ }
if(!videoContainer.value) return
videoContainer.value.removeEventListener('pointermove', onPointerMove);
videoContainer.value.removeEventListener('pointerup', onPointerUp);
videoContainer.value.removeEventListener('pointercancel', onPointerCancel);
activePointerId = null;
};
const onPointerUp = (ev) => {
if (ev.pointerId !== activePointerId) return;
// fin du geste : si ce n'est pas un slide, on ouvre le modal
const wasSliding = isSliding;
cleanupPointerListeners();
isSliding = false;
if (!wasSliding) {
modal.value.open();
}
};
const onPointerCancel = (ev) => {
if (ev.pointerId !== activePointerId) return;
cleanupPointerListeners();
isSliding = false;
};
const onPointerDown = (ev) => {
if (!ev) return;
if (ev.pointerType !== 'touch') return; // on gère seulement le touch ici
touchStartX = ev.clientX;
touchStartY = ev.clientY;
isSliding = false;
activePointerId = ev.pointerId;
if(!videoContainer.value) return
try { videoContainer.value.setPointerCapture(activePointerId); } catch (e) { /* ignore */ }
videoContainer.value.addEventListener('pointermove', onPointerMove);
videoContainer.value.addEventListener('pointerup', onPointerUp);
videoContainer.value.addEventListener('pointercancel', onPointerCancel);
};
videoContainer.value.addEventListener('pointerdown', onPointerDown);
const onResize = () => {
if (modal.value) modal.value.close();
};
window.addEventListener('resize', onResize);
onBeforeUnmount(() => {
// cleanup
if(!videoContainer.value) return
window.removeEventListener('resize', onResize);
});
});
</script>
<style scoped>
.selectorp {
margin: 0;
display: flex;
align-items: center;
gap: 3px;
}
.info-no {
color: var(--text-secondary);
font-size: 0.8em;
text-align: center;
}
.save-video {
max-width: 50%;
margin-left: auto;
margin-right: auto;
}
.controls {
position: absolute;
display: none;
justify-content: center;
align-items: center;
gap: 25px;
width: 100%;
bottom: 32%;
animation: fadeInMap 0.2s ease-in-out;
}
.control-icon {
font-size: 1.2em;
background-color: var(--text);
color: var(--text-inverse);
border-radius: 100%;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.8;
}
.video:hover .controls {
opacity: 1;
}
.text-secondary {
text-align: center;
color: var(--text-secondary);
font-size: 0.8em;
}
@keyframes fadeInMap {
0% {
opacity: 0;
gap: 5px;
}
100% {
opacity: 1;
gap: 25px;
}
}
</style>

View File

@@ -1,10 +1,79 @@
<template> <template>
<p>Test</p> <div class="default">
<!-- <Carousel v-show="!loading" class="child"> -->
<Changelog/>
<!-- <History/>
<Advice/>
</Carousel> -->
<p v-show="loading" class="loading">
<Icon icon="fa-spinner fa-solid" spin-pulse/> Chargement en cours
</p>
</div>
</template> </template>
<script setup> <script setup>
import Welcome from './Home/Welcome.vue';
import Carousel from '@/components/UI/Carousel.vue';
import Changelog from './Home/Changelog.vue';
import Advice from './Home/Advice.vue';
import History from './Home/History.vue';
import { ref } from 'vue';
import Events from '@/utils/Events';
const loading = ref(true);
Events.on("CHANGELOG_LOADED", () => {
loading.value = false;
});
</script> </script>
<style scoped> <style scoped>
.textSecond {
font-size: 0.8em;
color: var(--text-secondary);
opacity: 0.8;
}
p {
margin: 0 0 0.5em 0;
font-size: 1.5em;
font-weight: 600;
}
.default {
display: grid;
grid-template-rows: 1fr;
grid-template-columns: 1fr;
gap: 5px;
flex: 1;
}
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
.default {
grid-template-rows: 1fr;
}
}
.child {
grid-column: 1 / -1;
width: 100%;
}
.loading {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;;
color: var(--text-secondary);
}
</style> </style>

View File

@@ -0,0 +1,49 @@
<template>
<section>
<User mobile :user="userStore.userInfo" />
<div class="user-action">
<Button title="Paramètres" icon="fa-solid fa-gear" @click="userSettings.open()">Paramètres</Button>
<Button title="Déconnexion" color="red" icon="fa-solid fa-right-from-bracket" @click="signOut(router)">Déconnexion</Button>
</div>
<UserSettings ref="userSettings"/>
</section>
</template>
<script setup>
import IconAction from '@/components/UI/IconAction.vue';
import User from '@/components/UI/User.vue';
import { signOut } from '@/utils/UserRequest';
import { useRouter } from 'vue-router';
import { socket } from '@/socket.js';
import Button from '@/components/UI/Button.vue';
if(!socket.connected) {
socket.connect();
}
import { useUserStore } from '@/stores/userStore';
import UserSettings from '@/components/Widget/User/UserSettings.vue';
import { ref, onMounted } from 'vue';
const userStore = useUserStore();
const router = useRouter();
const userSettings = ref(null);
</script>
<style scoped>
.user-action {
display: flex;
align-items: center;
gap: 15px;
}
section {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 100%;
gap: 10px;
}
</style>

View File

@@ -0,0 +1,92 @@
<template>
<section class="advice">
<h2> Conseils du moment</h2>
<div class="advice-list">
<AdviceItem v-for="(advice, index) in randomAdvice" :key="index" :text="advice.text" :icon="advice.icon" />
</div>
</section>
</template>
<script setup>
import { onMounted, ref } from 'vue';
import AdviceItem from '@/components/Widget/View/Home/AdviceItem.vue';
const adviceList = ref([
{
text: "Les recherches acceptent aussi les liens YouTube, SoundCloud et Spotify.",
icon: "fa-search"
},
{
text: "Jouez vos propres musiques en cliquant sur le petit nuage à côté de la barre de recherche.",
icon: "fa-cloud-upload"
},
{
text: "Faites attention à ne pas couper la musique de quelquun dautre lorsque vous lancez un titre.",
icon: "fa-warning"
},
{
text: "Synchronisez votre compte YouTube pour récupérer toutes vos playlists, même privées.",
icon: "fa-youtube fa-brands"
},
{
text: "Vous devez être dans un salon vocal pour connecter le bot.",
icon: "fa-headset"
},
{
text: "Ajoutez vos titres préférés à une playlist personnelle pour les retrouver facilement.",
icon: "fa-heart"
},
{
text: "Recherchez un morceau directement en tapant un mot-clé dans la barre de recherche.",
icon: "fa-keyboard"
},
{
text: "Activez le mode 'shuffle' pour varier les plaisirs et découvrir de nouveaux titres.",
icon: "fa-shuffle"
},
{
text: "Un bug, un souci ou une idée ? Rendez-vous dans les paramètres pour envoyer un rapport.",
icon: "fa-bug"
},
{
text: "Votre playlist déchire ? Partagez-la avec vos amis !",
icon: "fa-share"
}
]);
// Get 3 random advice
const randomAdvice = ref([]);
const getRandomAdvice = () => {
const shuffled = adviceList.value.sort(() => 0.5 - Math.random());
randomAdvice.value = shuffled.slice(0, 5);
};
onMounted(() => {
getRandomAdvice();
});
</script>
<style scoped>
.advice {
display: flex;
flex-direction: column;
flex: 1;
}
.advice-list {
display: flex;
flex: 1;
flex-direction: column;
overflow-y: auto;
justify-content: space-around;
gap: 10px;
}
h2 {
font-size: 1.7em;
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<div class="advice-item">
<Icon class="advice-icon" :icon="`fa-solid ${props.icon}`"/>
<p>{{ props.text }}</p>
</div>
</template>
<script setup>
const props = defineProps({
text: String,
icon: String
});
</script>
<style scoped>
.advice-item {
display: flex;
align-items: center;
background-color: var(--tertiary);
border-radius: 10px;
padding: 12px;
font-size: 1.1em;
gap: 10px;
}
.advice-icon {
background-color: var(--quaternary);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
border-radius: 100%;
font-size: 1.5em;
width: 30px;
height: 30px;
}
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
.advice-item {
font-size: 0.8em;
}
.advice-icon {
width: 20px;
height: 20px;
padding: 20px;
font-size: 1.2em;
}
}
</style>

View File

@@ -0,0 +1,244 @@
<template>
<section class="changelog">
<Welcome/>
<h2 class="changelog-title">📝 Changelog</h2>
<div class="changelog-overflow" v-if="changelog" >
<div class="changelog-container" v-html="changelog" ></div>
</div>
<div class="textSecond" v-else-if="!error"><Icon icon="fa-spinner fa-solid" spin-pulse/> Chargement en cours</div>
<Error v-if="error">{{ error }}</Error>
</section>
</template>
<script setup>
import { ref, onMounted, shallowRef } from 'vue';
import { IORequest } from '@/utils/IORequest';
import Events from '@/utils/Events';
import Error from '@/components/UI/Error.vue';
import Welcome from './Welcome.vue';
const changelog = shallowRef(null);
const error = ref(null);
onMounted(() => {
loadChangelog();
});
function loadChangelog() {
Events.emit("CHANGELOG_LOADING");
IORequest("/CHANGELOG", (data) => {
if(data) {
data = data.replaceAll("[FRONTEND]", "<span class='tags frontend-tag'>Client</span>");
data = data.replaceAll("[BACKEND]", "<span class='tags backend-tag'>Serveur</span>");
data = data.replaceAll("[DISCORD]", "<span class='tags discord-tag'>Discord</span>");
data = data.replaceAll("[PLAYER]", "<span class='tags player-tag'>Player</span>");
data = data.replaceAll("[AUTRE]", "<span class='tags other-tag'>Autre</span>");
data = data.replaceAll("[FIX]", "<span class='tags bug-tag'>Fix</span>");
data = data.replaceAll("[AJOUT]", "<span class='tags add-tag'>Ajout</span>");
data = data.replaceAll("/*", "<span class='changelog-rounded'>");
data = data.replaceAll("*/", "</span>");
data = data.replaceAll("*-", "<span class='changelog-date-rounded'>");
data = data.replaceAll("-*", "</span>");
data = data.replaceAll("*_", "<span class='underline'>");
data = data.replaceAll("_*", "</span>");
changelog.value = data;
} else {
error.value = "Aucun changelog trouvé";
}
Events.emit("CHANGELOG_LOADED");
});
}
</script>
<style>
.changelog-title {
font-size: 1.7em;
font-weight: bold;
}
.changelog-version {
background-color: var(--tertiary);
padding: 10px;
border-radius: 10px;
width: 100%;
max-width: 100%;
box-sizing: border-box;
word-break: break-word;
overflow-wrap: anywhere;
}
.changelog-version h2 {
margin: 0 0 10px 0;
}
/* .changelog-version h2::before {
content: '🕐 ';
} */
.changelog-version ul {
border-radius: 10px;
color: var(--text-secondary);
max-width: 100%;
font-size: 0.8em;
display: flex;
flex-direction: column;
gap: 5px;
padding-left: 20px;
}
.changelog-version ul li {
display: flex;
align-items: center;
gap: 5px;
}
.changelog-version ul li::before {
content: '';
width: 6px;
height: 6px;
border-radius: 100%;
background-color: var(--text-secondary);
font-size: 0.8em;
}
.changelog-container {
display: grid;
grid-template-columns: 1fr;
width: 100%;
grid-template-rows: auto;
gap: 20px;
}
.changelog {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.changelog-overflow {
overflow-y: auto;
max-height: 100%;
}
.tags {
border-radius: 15px;
padding: 2px 6px;
color: var(--text);
white-space: nowrap; /* Prevent line breaks */
background-color: var(--secondary);
}
/* Make a circle before */
.tags::before {
content: '#';
display: inline-block;
font-size: 15px;
border-radius: 50%;
margin-right: 4px;
}
.backend-tag {
border: 1px solid rgb(255, 0, 221);
}
/* Make a circle before */
.backend-tag::before {
color: rgb(255, 0, 221);
}
.frontend-tag {
border: 1px solid rgb(29, 191, 255);
}
.frontend-tag::before {
color: rgb(29, 191, 255);
}
.other-tag {
border: 1px solid rgb(255, 29, 116);
}
.other-tag::before {
color: rgb(255, 29, 116);
}
.discord-tag {
border: 1px solid rgb(88, 101, 242);
}
.discord-tag::before {
color: rgb(88, 101, 242);
}
.player-tag {
border: 1px solid var(--text-warning);
}
.player-tag::before {
color: var(--text-warning);
}
.bug-tag {
border: 1px solid var(--text-error);
}
.bug-tag::before {
color: var(--text-error);
}
.add-tag {
border: 1px solid var(--text-success);
}
.add-tag::before {
color: var(--text-success);
}
/* .changelog-date::before {
content: '📆 ';
} */
.underline {
text-decoration: underline;
}
.changelog-actual h2 {
font-size: 1.4em;
}
/* .changelog-actual h2::before {
content: '✨ ';
} */
.changelog-date-rounded {
border-radius: 5px;
background-color: var(--secondary);
font-family: "Courier", sans-serif;
text-decoration: none;
padding: 2px 7px;
}
.changelog-rounded {
border-radius: 10px;
background-color: var(--secondary);
padding: 2px 7px;
border-radius: 15px;
border: 1px solid var(--text-secondary);
white-space: nowrap; /* Prevent line breaks */
}
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
.changelog-container {
grid-template-columns: 1fr !important;
}
.changelog-container ul {
padding: 0
}
.changelog-version ul li::before {
display: none;
}
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<div class="history-container">
<h2>🔄 Historique personnel</h2>
<p class="info">Voici les derniers titres que tu as écoutés (tout serveur confondu) !</p>
<div v-if="history && history.length" class="history-list">
<VideoComposable v-for="video in history" :key="id" :video="video" />
</div>
<p v-else-if="loading" class="none"><Icon icon="fa-spinner" spin-pulse /> Chargement en cours</p>
<p v-else class="none">
<span><Icon icon="fa-volume-xmark" /> Aucune chanson écoutée récemment.</span>
<span class="sub-info">Dès que tu écoutes une chanson, elle apparaîtra ici.</span>
</p>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue';
import { IORequest } from '@/utils/IORequest';
import VideoComposable from '@/components/Widget/VideoComposable.vue';
import Events from '@/utils/Events';
import { useId } from 'vue'
const id = useId()
const history = ref([]);
const loading = ref(true);
onMounted(() => {
IORequest('/USER/HISTORY', data => {
history.value = data.reverse();
loading.value = false;
});
})
Events.on("player:update", () => {
loading.value = true;
IORequest('/USER/HISTORY', data => {
history.value = data.reverse();
loading.value = false;
});
//FIXME: Error on the history
})
</script>
<style scoped>
.history-container {
display: flex;
flex-direction: column;
flex: 1;
gap: 8px;
}
h2 {
font-size: 1.7em;
margin: 0;
}
.none {
width: 100%;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
color: var(--text-secondary);
font-size: 1.5em;
gap: 10px;
flex: 1;
}
.none span {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.sub-info {
font-size: 0.7em;
margin: 0;
color: var(--text-secondary);
}
.info {
font-size: 0.9em;
color: var(--text-secondary);
}
.history-list {
flex: 1;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
}
</style>

View File

@@ -0,0 +1,71 @@
<template>
<p>{{ welcomePhrase }}</p>
</template>
<script setup>
import { onMounted, ref } from 'vue';
const welcomePhrase = ref('');
const welcomePhrases = [
// 🎶 Musique
"Prêt à faire vibrer les serveurs ? 🎧",
"Le beat n'attend que toi 🔊",
"Monte le son, baisse les soucis ! 🎶",
"La playlist est vide… pour linstant 👀",
"Que la musique commence ! 🎵",
// 🧠 Motivantes
"Chaque jour est une nouvelle chanson à écrire. ✍️",
"Tu nes quà un clic dune bonne vibe. ⚡",
"Ta créativité est ton meilleur plugin. 🎛️",
"Aujourdhui, tu vas casser des tympans (positivement). 💥",
// 😎 Cool & relax
"Toujours en retard, mais toujours stylé 😎",
"Connecté ? Que la fête commence. 🥳",
"Tu sens cette vibe ? Cest la tienne. 🌊",
"Encore un jour pour être incroyable. 🌟",
// 🕹️ Geek / dev
"Chargement de lambiance… 100% ✅",
"Console ready. Exécutez `!play` 🖥️",
"Ton interface préférée tattend. 💻",
"Mise à jour du swag terminée. 🔄",
// 🤖 Humour IA / tech
"Je tai généré une bonne humeur en .mp3 🤖",
"Aucune erreur 404 aujourdhui (je crois). 🐛",
"Bienvenue dans le multivers du son. 🪐",
"Système audio synchronisé, capitaine. 🚀",
// 🧪 Expérimental / fun
"Statut : chill activé 😌",
"Nombre de décibels autorisés : illimité. 🔊",
"Café : ☕ | Motivation : 🔥 | Playlists : 🆙",
"Pas besoin de wifi pour vibrer (enfin presque). 📡",
// 🎯 Dynamique
"Tu es le DJ de ta propre histoire. 🎧",
"Aujourdhui, cest toi la star. 🌟",
"Envie de faire bouger les choses ? Lets go. 💃",
"Un bon son, une bonne journée. ☀️",
// 👨‍🎤 Personnalisées
"Raphix, la scène tappelle. 🎤",
"Tu fais fondre les playlists 🔥",
"Mieux quun jukebox vivant. 🎚️"
]
onMounted(() => {
welcomePhrase.value = welcomePhrases[Math.floor(Math.random() * welcomePhrases.length)];
})
</script>
<style scoped>
p {
font-size: 1.2em;
color: var(--text-secondary);
text-align: center;
margin: 0;
}
</style>

View File

@@ -0,0 +1,72 @@
<template>
<div class="welcome-container">
<div class="welcome">
<Avatar img-class="avatar-welcome" :user-id='userStore.userInfo.identity.id' :avatar-url="userStore.userInfo.identity.avatar" />
<div>
<h2>Bienvenue, {{ userStore.userInfo.identity.global_name }} !</h2>
<Motivation class="mot"/>
</div>
</div>
<div class="welcome-actions">
<Button icon="fa-question" @click="Events.emit('VIEW_HELP')">Conseil</Button>
<Button icon="fa-history" @click="Events.emit('VIEW_HISTORY')">Historique personnel</Button>
</div>
</div>
</template>
<script setup>
import Avatar from '@/components/UI/Avatar.vue';
import { useUserStore } from '@/stores/userStore';
import Motivation from '@/components/Widget/View/Home/Motivation.vue';
import Button from '@/components/UI/Button.vue';
import Events from '@/utils/Events';
const userStore = useUserStore();
</script>
<style scoped>
.welcome {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 20px;
}
.welcome-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 10px;
}
.welcome-container {
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
}
h2 {
margin: 0;
}
p {
margin: 10px 0;
color: var(--text-secondary);
font-size: 0.9em;
}
.mot {
font-size: 0.8em;
text-align: start;
}
@media screen and (max-width: 768px), screen and (max-height: 607px) {
.welcome-actions {
align-items: initial;
}
}
</style>

View File

@@ -2,7 +2,7 @@
<div class="loading"> <div class="loading">
<div class="loading-content"> <div class="loading-content">
<Icon icon="fa-solid fa-spinner" spin-pulse /> <Icon icon="fa-solid fa-spinner" spin-pulse />
<span>{{ message }}</span> <span class="message">{{ message }}</span>
</div> </div>
<span class="second">Un peu de patience, le groupe se prépare !</span> <span class="second">Un peu de patience, le groupe se prépare !</span>
</div> </div>
@@ -11,7 +11,7 @@
const props = defineProps({ const props = defineProps({
message: { message: {
type: String, type: String,
default: 'Chargement en cours...' default: 'Chargement en cours'
} }
}); });
</script> </script>
@@ -23,6 +23,7 @@ const props = defineProps({
justify-content: center; justify-content: center;
height: 100%; height: 100%;
font-size: 2rem; font-size: 2rem;
flex: 1;
gap: 10px; gap: 10px;
color: var(--text-tertiary); color: var(--text-tertiary);
} }
@@ -38,4 +39,15 @@ const props = defineProps({
color: var(--text-secondary); color: var(--text-secondary);
opacity: 0.8; opacity: 0.8;
} }
.message {
font-size: 2rem;
}
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
.message {
font-size: 0.8em;
}
}
</style> </style>

View File

@@ -0,0 +1,40 @@
<template>
<div class="channel-message">
<Tag color="var(--text-error)" v-if="!globalStore.currentChannel"><span class="tag-channel"><span>Vous</span> <span>êtes</span> déconnecté </span></Tag>
<Tag color="var(--text-success)" v-else><span class="tag-channel">Connecté <span>à </span> <Icon icon="fa-solid fa-volume-up"/> <span>{{ globalStore.currentChannel.name }}</span></span></Tag>
</div>
</template>
<script setup>
import { useGlobalStore } from '@/stores/globalStore';
import Tag from '@/components/UI/Tag.vue';
import { onMounted } from 'vue';
import { updateChannel } from '@/utils/Logic';
const globalStore = useGlobalStore();
onMounted(() => {
updateChannel()
})
</script>
<style scoped>
.channel-message {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
}
.channel-message p {
margin: 0;
}
.tag-channel {
font-size: 1.2em;
display: flex;
align-items: center;
gap: 3px;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,95 @@
<template>
<div class="playlist-view">
<PlaylistHeader :is-playlist="true" :results="props.playlist"/>
<p ref="successMessage" v-if="hasBeenActualized"> <Success>Cette playlist vient d'être mise à jour.</Success> </p>
<div class="search-playlist-results-container">
<div v-if="props.playlist?.songs?.length > 0" class="search-playlist-results">
<VideoComposable delete v-for="result in props.playlist.songs" :key="result.id" :video="result"/>
</div>
<div v-else class="search-playlist-no-results">
<p><Icon icon="fa-ban"/> Aucun titre</p>
<p class="info-no">Vous n'avez pas encore ajouté de titre à cette playlist. <br/> Faites une recherche et sauvegarder les avec <Icon icon="fa-solid fa-save"/> </p>
</div>
</div>
</div>
</template>
<script setup>
import { useUserStore } from '@/stores/userStore';
import PlaylistHeader from '../Search/PlaylistHeader.vue';
import VideoComposable from '@/components/Widget/VideoComposable.vue';
import Success from '@/components/UI/Success.vue';
import { onMounted, ref } from 'vue';
const userStore = useUserStore();
const successMessage = ref(null);
const props = defineProps({
playlist: {
type: Object,
required: true
},
hasBeenActualized: {
type: Boolean,
default: false
}
})
onMounted(() => {
setTimeout(() => {
if(successMessage.value)
successMessage.value.style.display = "none";
}, 5000);
})
</script>
<style scoped>
.playlist-view {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
}
.info-no {
font-size: 0.5em;
color: var(--text-secondary);
}
.search-playlist-no-results {
text-align: center;
flex-direction: column;
font-size: 1.7em;
color: var(--text-secondary);
opacity: 0.8;
flex: 1;
display: flex;
gap: 5px;
align-items: center;
justify-content: center;
position: absolute;
}
.search-playlist-results-container {
display: flex;
position: relative;
overflow-y: auto;
justify-content: center;
flex: 1;
}
.search-playlist-no-results p {
margin: 0;
}
.search-playlist-results {
display: grid;
grid-template-columns: repeat(auto-fill,minmax(200px,auto));
gap: 10px;
flex: 1;
width: 100%;
position: absolute;
}
</style>

View File

@@ -0,0 +1,448 @@
<template>
<div class="search-playlist-container">
<div class="search-playlist-header">
<img v-if="results.thumbnail" class="search-playlist-thumbnail" :src="results.thumbnail" alt="Playlist Thumbnail" />
<div v-else class="search-playlist-thumbnail"><div class="defaultIcon"><Icon icon="fa-music" /></div></div>
<div class="search-playlist-info">
<p class="search-playlist-title">{{ results.title }} <Icon @click="openPlaylistPage()" class="link-icon" icon="fa-solid fa-link" /></p>
<p class="search-playlist-stats"><Tag color="var(--text-warning)">{{ results.songs.length }} titres</Tag><Tag v-if="results.views" color="var(--text-success)">{{ results.views }} vues</Tag ><Tag v-if="results.songs.length > 0">Durée : {{ results.readduration }}</Tag></p>
<div @click="openAuthorPage()" class="search-playlist-author-info">
<img v-if="results.authorAvatar" :src="results.authorAvatar" alt="Author Thumbnail" class="search-playlist-author" />
<div v-else class="search-playlist-author-placeholder"></div>
<p v-if="results.author" class="search-playlist-author-name">{{ results.author }}</p>
<p v-else class="search-playlist-author-name">Aucun auteur trouvé</p>
</div>
</div>
</div>
<section :class="{'search-playlist-act-container': true, 'search-playlist-act-container-box': messagePlaylist}">
<div class="search-playlist-actions">
<AddList
icon-action
title="Ajouter à la liste de lecture"
@click="playPlaylist()"
v-if="globalStore.currentChannel"
/>
<IconAction
icon="fa-solid fa-play"
title="Lire maintenant la playlist"
@click="playPlaylist(true)"
v-if="globalStore.currentChannel"
/>
<IconAction v-if="!isPlaylist"
icon="fa-solid fa-save"
title="Sauvegarder la playlist"
@click="savePlaylist()"
/>
<IconAction v-if="isPlaylist"
icon="fa-solid fa-sliders"
title="Configurer la playlist"
@click="settingsModal.open()"/>
</div>
<Success class="playlist-message" v-if="messagePlaylist">{{ messagePlaylist }}</Success>
</section>
</div>
<Modal ref="settingsModal" title="Configuration de la playlist" icon="fa-solid fa-sliders">
<ModalTree title="Actions">
<div class="modal-actions">
<Button v-if="isPlaylist && results.type != 'youtube'"
icon="fa-solid fa-pen-to-square"
@click="renameModal.open(); renameInput = results.title"
>Renommer la playlist</Button>
<Button v-if="isPlaylist && results.type == 'youtube'"
@click="Events.emit('playlist:refresh', results); isRefreshing = true"
><YoutubeRefresh /> Mettre à jour la playlist</Button>
<Button
icon="fa-solid fa-trash"
@click="deleteModal.open()"
>Supprimer la playlist</Button>
</div>
<p class="info-loading" v-if="isRefreshing"><Icon icon="fa-solid fa-spinner" spin-pulse /> Mise à jour en cours ...</p>
</ModalTree>
<ModalTree icon="fa-solid fa-circle-info" title="Informations">
<div class="playlist-conf-info">
<p><Icon :icon="results.type == 'youtube' ? 'fa-brands fa-youtube' : 'fa-solid fa-music'" /> <span class="pconf-key">Titre:</span> <span class="pconf-value">{{ results.title }}</span></p>
<p><Icon icon="fa-solid fa-user" /> <span class="pconf-key">Auteur:</span> <span class="pconf-value">{{ results.author }}</span></p>
<p><Icon icon="fa-solid fa-clock" /> <span class="pconf-key">Durée:</span> <span class="pconf-value">{{ results.readduration ? results.readduration : 'Inconnue' }}</span></p>
<p><Icon icon="fa-solid fa-list" /> <span class="pconf-key">Nombre de titres:</span> <span class="pconf-value">{{ results.songs.length }}</span></p>
<p v-if="results.views"><Icon icon="fa-solid fa-eye" /> <span class="pconf-key">Vues:</span> <span class="pconf-value">{{ results.views }}</span></p>
<p v-if="results.url"><Icon icon="fa-solid fa-link" /> <span class="pconf-key">Lien:</span> <a :href="results.url" target="_blank">{{ results.url }}</a></p>
</div>
</ModalTree>
</Modal>
<Modal ref="renameModal" title="Renommer la playlist" icon="fa-solid fa-pen-to-square">
<div class="modal-rename">
<input type="text" v-model="renameInput" placeholder="Nouveau nom de la playlist" />
<Button :disabled="!renameInput.trim('')" @click="Events.emit('playlist:rename', { id: results.playlistId, newName: renameInput })">Renommer</Button>
</div>
</Modal>
<Modal ref="deleteModal" title="Supprimer une playlist" icon="fa-solid fa-trash">
<p>Êtes-vous sûr de vouloir supprimer cette playlist ?</p>
<p class="info-no">Cette action est irréversible et la playlist ne pourra pas être récupéré.</p>
<p class="info-name"><Icon :icon="results.type == 'youtube' ? 'fa-brands fa-youtube' : 'fa-solid fa-music'" /> {{ results.title }}</p>
<div class="info-actions">
<Button @click="deleteModal.close()">Annuler</Button>
<Button @click="Events.emit('playlist:delete', results); deleteModal.close()">Supprimer</Button>
</div>
</Modal>
</template>
<script setup>
import Tag from '@/components/UI/Tag.vue';
import IconAction from '@/components/UI/IconAction.vue';
import Events from '@/utils/Events';
import Modal from '@/components/UI/Modal.vue';
import Button from '@/components/UI/Button.vue';
import { onMounted, ref } from 'vue';
import ModalTree from '@/components/UI/ModalTree.vue';
import { IORequest } from '@/utils/IORequest';
import { useGlobalStore } from '@/stores/globalStore';
import AddList from '@/assets/Icons/AddList.vue';
import YoutubeRefresh from '@/assets/Icons/YoutubeRefresh.vue';
import Success from '@/components/UI/Success.vue';
const deleteModal = ref(null);
const renameModal = ref(null);
const renameInput = ref('');
const settingsModal = ref(null);
const isRefreshing = ref(false);
const globalStore = useGlobalStore();
const messagePlaylist = ref(null);
const props = defineProps({
results: {
type: Object,
required: true
},
isPlaylist: {
type: Boolean,
required: false
}
});
function openAuthorPage() {
if (!props.results.authorId) return;
window.open(props.results.authorId, '_blank');
}
function openPlaylistPage() {
if (!props.results.url) return;
window.open(props.results.url, '_blank');
}
function savePlaylist() {
if (!props.results.url) return;
IORequest("/PLAYLISTS/CREATE", (response) => {
if(response) {
Events.emit("playlist:open", response);
Events.emit("logic:init")
}
}, { name: 'default', url: props.results.url });
}
Events.on("playlist:hasRefresh", (playlist) => {
isRefreshing.value = false;
if(settingsModal.value) settingsModal.value.close();
});
function playPlaylist(now) {
IORequest("/PLAYLISTS/PLAY", (data) => {
}, { name: props.results.playlistId, now: now });
if(now) {
setMessage("Lecture de la Playlist");
} else {
setMessage("Playlist ajoutée");
}
}
function setMessage(message) {
messagePlaylist.value = message;
setTimeout(() => {
messagePlaylist.value = null;
}, 5000);
}
onMounted(() => {
isRefreshing.value = false;
})
</script>
<style scoped>
.info-loading {
color: var(--text-secondary);
display: flex;
gap: 5px;
font-size: 0.8em;
justify-content: center;
width: 100%;
}
.playlist-conf-info {
padding: 10px;
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 5px;
background-color: var(--tertiary);
border-radius: 10px;
}
.search-playlist-act-container {
display: flex;
flex-direction: column;
gap: 10px;
align-items: center;
justify-content: center;
position: relative;
}
.playlist-message {
bottom: -25px;
font-size: 0.8em;
position: absolute;
}
.playlist-conf-info p {
display: flex;
align-items: center;
gap: 5px;
}
.playlist-conf-info svg {
width: 1em;
height: 1em;
}
.pconf-key {
font-weight: bold;
}
.pconf-value {
color: var(--text-secondary);
font-size: 0.9em;
}
.playlist-conf-info a {
color: var(--text-secondary);
font-size: 0.7em;
word-break: break-all;
}
.modal-actions {
display: flex;
align-items: center;
margin: 10px 0;
gap: 10px;
}
.modal-rename {
display: flex;
flex-direction: column;
gap: 10px;
}
@media screen and (max-width: 768px), screen and (max-height: 607px) {
.modal-actions {
flex-direction: column;
align-items: stretch;
}
}
.modal-actions Button {
flex: 1;
}
.info-no {
color: var(--text-secondary);
font-size: 0.8em
}
.info-name {
background-color: var(--tertiary);
padding: 5px 10px;
border-radius: 5px;
}
.info-actions {
display: flex;
justify-content: space-between;
flex: 1;
gap: 10px;
}
.info-actions Button {
width: 100%;
}
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
.info-actions {
flex-direction: column;
align-items: flex-start;
}
}
.link-icon {
display: none;
font-size: 0.8em;
color: var(--text-secondary);
animation: fadeIn 0.2s ease;
cursor: pointer;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.search-playlist-title:hover .link-icon {
display: flex;
}
.defaultIcon {
border: 2px solid var(--text);
display: flex;
align-items: center;
justify-content: center;
flex: 1;
font-size: 4em;
border-radius: 10px;
}
.search-playlist-container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.search-playlist-act-container-box {
margin-bottom: 25px;
}
.search-playlist-actions {
display: flex;
align-items: center;
gap: 15px;
font-size: 1.2em;
gap: 30px;
background-color: var(--tertiary);
padding: 8px 15px;;
border-radius: 30px;
}
.search-playlist-author {
width: 30px;
border-radius: 100%;
aspect-ratio: 1/1;
object-fit: cover;
}
.search-playlist-author-info {
display: flex;
align-items: center;
color: var(--text-secondary);
gap: 5px;
cursor: pointer;
}
.search-playlist-author-name {
font-size: 0.8em;
}
.search-playlist-thumbnail {
aspect-ratio: 1/1;
width: 150px;
border-radius: 10px;
object-fit: cover;
display: flex;
}
.search-playlist-header {
display: flex;
align-items: center;
width: 100%;
gap: 10px;
}
.search-playlist-title {
display: flex;
align-items: center;
gap: 5px;
font-weight: bold;
font-size: 1.2em;
}
@media screen and (max-width: 1020px), screen and (max-height: 607px) {
.search-playlist-title {
font-size: 1em;
}
.search-playlist-container {
display: flex;
flex-direction: column;
gap: 20px;
}
.search-playlist-actions {
display: flex;
justify-content: center;
align-items: center;
}
.search-playlist-title:hover .link-icon {
display: none;
}
}
.search-playlist-info {
display: flex;
flex-direction: column;
gap: 10px;
}
.search-playlist-stats {
display: flex;
gap: 5px;
}
.search-playlist-duration {
display: flex;
align-items: center;
}
.search-playlist-author-placeholder {
width: 30px;
border-radius: 100%;
aspect-ratio: 1/1;
background-color: var(--primary);
object-fit: cover;
}
p {
margin: 0;
}
@media screen and (max-width: 768px), screen and (max-height: 607px) {
.search-playlist-stats {
flex-direction: column;
align-self: start;
}
}
</style>

View File

@@ -1,3 +1,93 @@
<template> <template>
<div class="search-playlist" v-if="props.playlist">
<PlaylistHeader :results="props.results" />
<div class="search-playlist-results">
<div class="search-results-container">
<VideoComposable v-for="result in props.results.songs" :key="result.id" :video="result"/>
</div>
</div>
</div>
<div v-else-if="props.results?.length > 0" class="search-results">
<div class="search-results-container">
<VideoComposable v-for="result in props.results" :key="result.id" :video="result"/>
</div>
</div>
<div v-else class="search-error">
<Icon icon="fa-circle-question" />
<p>Aucun résultat trouvé pour <strong style="word-wrap: break-word;">"{{ query }}"</strong></p>
</div>
</template> </template>
<script setup>
import { ref } from 'vue';
import VideoComposable from '@/components/Widget/VideoComposable.vue';
import PlaylistHeader from '@/components/Widget/View/Search/PlaylistHeader.vue';
const props = defineProps({
results: {
required: true
},
query: {
type: String,
required: true
},
playlist: {
type: Boolean,
default: false
}
});
</script>
<style lang="css" scoped>
.search-results {
flex: 1;
display: flex;
overflow-y: auto;
position: relative;
}
.search-results-container {
display: grid;
grid-template-columns: repeat(auto-fill,minmax(200px,auto));
gap: 10px;
position: absolute;
width: 100%;
}
.search-playlist {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
}
.search-playlist-results {
display: grid;
grid-template-columns: repeat(auto-fill,minmax(200px,auto));
gap: 10px;
flex: 1;
overflow-y: auto;
position: relative;
}
.search-error {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-size: 1.5em;
color: var(--text-secondary);
}
.search-error p {
margin: 0;
max-width: 50%;
}
</style>

View File

@@ -0,0 +1,257 @@
<template>
<section class="upload-files">
<div class="files-header">
<h2><Icon icon="fa-folder"/> Mes fichiers</h2>
<Box box-class="area-container" padding="close" level="second">
<div class="upload-area">
<Button :color-lower="status === 'download'" :disabled="status === 'download'" class="upload-button" @click="uploadFile()"><Icon icon="fa-upload"/> Ajouter un fichier</Button>
<p v-if="status === 'download'" class="upload-file-name" ref="uploadStatus"><Icon icon='fa-spinner' spin-pulse/> Téléchargement en cours...</p>
<p v-else-if="status === 'error'" class="upload-status" ref="uploadStatus"><Error>Erreur lors du téléchargement</Error></p>
<p v-else-if="status === 'toohigh'" class="upload-status" ref="uploadStatus"><Error>Le fichier est trop volumineux</Error></p>
<p v-else-if="status === 'success'" class="upload-status" ref="uploadStatus"><Success>Fichier téléchargé avec succès</Success></p>
<p v-else class="upload-status" ref="uploadStatus">Aucun fichier séléctionné</p>
</div>
<p class="text-secondary infosup"><Icon icon="fa-circle-info"/> Ce système n'est pas un stockage permanent de données car il dépend du CDN de Discord. Vos fichiers peuvent exprirer à tout moment.</p>
</Box>
</div>
<div v-if="myFiles && myFiles.length > 0" class="uploaded-files">
<span v-for="file in myFiles" :key="file.id"><VideoComposable :video="file" delete/></span>
</div>
<p v-else-if="isLoading" class="none"><Icon icon="fa-spinner" spin-pulse/> Chargement des fichiers...</p>
<p v-else class="none"><Icon icon="fa-circle-xmark"/> Aucun fichier enregistré</p>
<Modal icon="fa-upload" title="Uploader un fichier" ref="uploadModal">
<p>Etes-vous sûr de vouloir uploader ce fichier ?</p>
<p class="text-secondary">Ce fichier sera stocké sur le CDN de Discord et sera à jamais accessible. Ne diffusez rien de sensible.</p>
<p v-if="fileSelected" class="upload-modal-name"><Icon icon="fa-file"/> {{ fileSelected.name }}</p>
<div class="upload-actions">
<Button @click="closeModal()">Annuler</Button>
<Button @click="confirmUpload()">Confirmer</Button>
</div>
</Modal>
</section>
</template>
<script setup>
import Box from '@/components/UI/Box.vue';
import Button from '@/components/UI/Button.vue';
import Modal from '@/components/UI/Modal.vue';
import Success from '@/components/UI/Success.vue';
import Error from '@/components/UI/Error.vue';
import VideoComposable from '@/components/Widget/VideoComposable.vue';
import { IORequest } from '@/utils/IORequest';
import { onMounted, onUnmounted, ref } from 'vue';
import Events from '@/utils/Events';
const fileSelected = ref(null);
const status = ref(false);
const uploadModal = ref(null);
const myFiles = ref([]);
const isLoading = ref(true);
onMounted(() => {
refreshUploadedFiles();
Events.on("video:delete", deleteFile);
})
onUnmounted(() => {
Events.off("video:delete", deleteFile);
});
function deleteFile(data) {
IORequest('/UPLOAD/FILE/DELETE', (response) => {
refreshUploadedFiles();
}, data.video);
}
function uploadFile() {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.mp3,.wav,.ogg'; // Accept audio files
input.onchange = (event) => {
const file = event.target.files[0];
if (file) {
fileSelected.value = file;
// Here you would typically handle the file upload to the server
console.log(`File selected: ${file.name}`);
// Reset the input for future uploads
input.value = '';
// destroy input
input.remove();
uploadModal.value.open();
} else {
fileSelected.value = null;
}
};
input.click();
}
function confirmUpload() {
console.log(`Uploading file: ${fileSelected.value.name}`);
status.value = 'download';
uploadModal.value.close();
if(fileSelected.value) {
// Send the file to the server
const reader = new FileReader();
reader.onload = () => {
const fileBuffer = reader.result;
// If it's higher than 300mb
if (fileBuffer.byteLength > 300 * 1024 * 1024) {
status.value = 'toohigh';
console.error('File is too large');
return;
}
IORequest('/UPLOAD/FILE', (response) => {
if(!response) {
status.value = 'error';
} else if(response === "TOOHIGH") {
status.value = 'toohigh';
} else {
status.value = 'success';
}
refreshUploadedFiles();
}, {name: fileSelected.value.name, file: fileBuffer})
fileSelected.value = null;
};
reader.readAsArrayBuffer(fileSelected.value);
}
}
function closeModal() {
uploadModal.value.close();
fileSelected.value = null;
status.value = false;
}
function refreshUploadedFiles() {
myFiles.value = [];
isLoading.value = true;
IORequest('/UPLOAD/FILES', (response) => {
if (response) {
myFiles.value = response;
} else {
myFiles.value = [];
}
isLoading.value = false;
});
}
</script>
<style scoped>
.infosup {
margin: 0 10px 10px 10px;
text-align: justify;
}
.uploaded-files {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
}
.none {
text-align: center;
font-size: 1.5em;
color: var(--text-secondary);
opacity: 0.8;
flex: 1;
display: flex;
gap: 10px;
align-items: center;
justify-content: center;
}
.files-header {
display: flex;
flex-direction: column;
gap: 10px;
padding: 5px;
}
.text-secondary {
font-size: 0.8em;
color: var(--text-secondary);
opacity: 0.8;
}
.upload-actions {
display: flex;
gap: 10px;
}
.upload-actions Button {
width: 100%;
}
.upload-status {
font-size: 0.9em;
color: var(--text-secondary);
opacity: 0.8;
}
.upload-file-name {
font-size: 1em;
color: var(--text-primary);
font-weight: bold;
}
.upload-modal-name {
font-size: 0.8em;
color: var(--text-primary);
background-color: var(--tertiary);
padding: 5px 10px;
border-radius: 5px;
display: flex;
align-items: center;
gap: 5px;
}
.upload-area {
display: flex;
align-items: center;
border-radius: 10px;
padding: 10px 10px;
gap: 10px;
}
.upload-files {
display: flex;
flex-direction: column;
gap: 10px;
flex: 1;
}
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
.upload-area {
flex-direction: column;
}
.upload-button {
order: 1;
}
.area-container {
width: 100%;
}
.upload-button {
width: 100%;
}
.upload-actions {
flex-direction: column;
}
}
p {
margin: 0;
}
.files-header h2 {
margin: 0;
font-size: 1.5em;
}
</style>

View File

@@ -5,7 +5,11 @@ export const useGlobalStore = defineStore('global', () => {
const isLoading = ref(true); const isLoading = ref(true);
const lastRoute = ref(null); const lastRoute = ref(null);
const lastGuild = ref(localStorage.getItem("lastGuild") || null); const lastGuild = ref(localStorage.getItem("lastGuild") || null);
const isViewShowing = ref(false);
const actualServer = ref(null);
const theme = ref(localStorage.getItem("theme") || "system"); const theme = ref(localStorage.getItem("theme") || "system");
const actualPlaylistId = ref(null);
const currentChannel = ref(null);
if(theme.value === "system") { if(theme.value === "system") {
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
theme.value = systemTheme; theme.value = systemTheme;
@@ -55,6 +59,10 @@ export const useGlobalStore = defineStore('global', () => {
toogleTheme, toogleTheme,
lastGuild, lastGuild,
setLastGuild, setLastGuild,
setTheme setTheme,
isViewShowing,
actualPlaylistId,
actualServer,
currentChannel
}; };
}) })

View File

@@ -3,6 +3,7 @@ import { ref, watch } from 'vue';
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
const userInfo = ref(null); const userInfo = ref(null);
const playlists = ref(null);
watch(userInfo, (newValue) => { watch(userInfo, (newValue) => {
if (newValue) { if (newValue) {
@@ -24,6 +25,7 @@ export const useUserStore = defineStore('user', () => {
return { return {
userInfo, userInfo,
playlists,
setUserInfo, setUserInfo,
clearUserInfo, clearUserInfo,
}; };

View File

@@ -10,7 +10,13 @@ export default {
}, },
off(eventName, fn) { off(eventName, fn) {
throw {message:'Not implemented'} if(Events.has(eventName)) {
const listeners = Events.get(eventName)
const index = listeners.indexOf(fn)
if(index !== -1) {
listeners.splice(index, 1)
}
}
}, },
emit(eventName, data) { emit(eventName, data) {
if(Events.has(eventName)) { if(Events.has(eventName)) {

View File

@@ -1,5 +1,6 @@
<script setup> <script setup>
import DefaultSplash from '@/components/UI/DefaultSplash.vue'; import DefaultSplash from '@/components/UI/DefaultSplash.vue';
import MusicAnimation from '@/components/UI/MusicAnimation.vue';
const defaultMessage = "On s'accorde et on prépare le concert !"; const defaultMessage = "On s'accorde et on prépare le concert !";
const connectMsg = "Erreur de connexion au serveur : xhr poll error" const connectMsg = "Erreur de connexion au serveur : xhr poll error"
@@ -14,8 +15,8 @@ const props = defineProps({
</script> </script>
<template> <template>
<DefaultSplash> <DefaultSplash gap="0">
<h1 v-if="!interuptionMessage" class="separate"><Icon spin-pulse icon="fa-solid fa-spinner"/> Chargement de l'interface</h1> <h1 v-if="!interuptionMessage" class="separate"> Chargement de l'interface</h1>
<h1 v-else-if="interuptionMessage == connectMsg"><Icon icon="fa-solid fa-circle-exclamation"/> Echec de connexion</h1> <h1 v-else-if="interuptionMessage == connectMsg"><Icon icon="fa-solid fa-circle-exclamation"/> Echec de connexion</h1>
<h1 v-else><Icon icon="fa-solid fa-warning"/> Connexion interrompue</h1> <h1 v-else><Icon icon="fa-solid fa-warning"/> Connexion interrompue</h1>
<div class="separate-col"> <div class="separate-col">
@@ -24,6 +25,7 @@ const props = defineProps({
</div> </div>
<p v-if="interuptionMessage" class="error"><Icon icon="fa-solid fa-circle-xmark"/> {{ interuptionMessage }}</p> <p v-if="interuptionMessage" class="error"><Icon icon="fa-solid fa-circle-xmark"/> {{ interuptionMessage }}</p>
<p v-else>{{ defaultMessage }}</p> <p v-else>{{ defaultMessage }}</p>
<MusicAnimation />
</DefaultSplash> </DefaultSplash>
</template> </template>
<style scoped> <style scoped>
@@ -60,4 +62,11 @@ const props = defineProps({
font-size: 0.8em; font-size: 0.8em;
color: var(--text-secondary); color: var(--text-secondary);
} }
@media screen and (max-width: 768px),
screen and (max-height: 607px) {
h1 {
font-size: 6vw;
}
}
</style> </style>

103
src/utils/Logic.js Normal file
View File

@@ -0,0 +1,103 @@
import Events from "@/utils/Events";
import { IORequest, IOListener } from "@/utils/IORequest";
import { useGlobalStore } from "@/stores/globalStore";
import { useUserStore } from "@/stores/userStore";
export function loadLogic() {
const globalStore = useGlobalStore();
Events.on("playlist:delete", (playlist) => {
IORequest("/PLAYLISTS/DELETE", () => {
Events.emit("VIEW_RESET")
Events.emit("playlistComponent:update")
refreshPlaylist()
}, playlist.playlistId);
});
Events.on("video:add", (video) => {
IORequest("/PLAYLISTS/ADD_SONG", (response) => {
if(response) {
refreshPlaylist()
openPlaylist(response);
} else {
console.error("Failed to add video");
}
}, { id: video.playlistId, song: video.video });
});
Events.on("playlist:refresh", (playlist) => {
IORequest("/PLAYLISTS/REFRESH", async (data) => {
Events.emit("playlist:hasRefresh");
refreshPlaylist()
if(!data) return
await openPlaylist(data, true);
}, playlist.playlistId)
})
Events.on("playlist:rename", (data) => {
IORequest("/PLAYLISTS/RENAME", async (response) => {
if(response) {
refreshPlaylist()
openPlaylist(response);
} else {
console.error("Failed to rename playlist");
}
}, { id: data.id, newName: data.newName });
});
Events.on("video:delete", (video) => {
if(!globalStore.actualPlaylistId) return;
IORequest("/PLAYLISTS/REMOVE_SONG", (response) => {
if(response) {
refreshPlaylist()
openPlaylist(response);
} else {
console.error("Failed to delete video");
}
}, { id: globalStore.actualPlaylistId, songId: video.video.id });
});
Events.on("view:change", (component) => {
if(component.__name === "PlaylistView") return;
if(component.__name === "LoadingView") return;
globalStore.actualPlaylistId = null;
});
Events.on("playlist:open", openPlaylist);
Events.on("logic:init", refreshPlaylist);
}
export function refreshPlaylist() {
const userStore = useUserStore();
IORequest("/PLAYLISTS/LIST", (data) => {
userStore.playlists = data;
Events.emit("playlist:init", data);
})
}
export function openPlaylist(playlist, hasBeenActualized) {
const globalStore = useGlobalStore();
Events.emit("VIEW_PLAYLIST", {playlist, hasBeenActualized});
globalStore.actualPlaylistId = playlist.playlistId;
console.log("Opening playlist:", playlist.title, "with ID:", playlist.playlistId);
}
export function updateChannel() {
const globalStore = useGlobalStore();
globalStore.currentChannel = null;
IORequest("/CHANNEL", (data) => {
if(data) {
console.log(globalStore.actualServer)
console.log(data.guildId)
if(data.guildId === globalStore.actualServer.id) {
globalStore.currentChannel = data;
}
if(globalStore.currentChannel.id === globalStore.actualServer.id) {
globalStore.currentChannel = null;
}
} else {
globalStore.currentChannel = null;
}
})
}

View File

@@ -39,6 +39,14 @@ import events from '@/utils/Events.js';
}); });
}); });
IOListener("/GUILD/UPDATE", () => {
IORequest("/GUILD/LIST", (response) => {
if(response) {
userStore.userInfo.guilds = response;
}
})
})
IOListener("AUTH_ERROR", (error) => { IOListener("AUTH_ERROR", (error) => {
console.error("Authentication error:", error); console.error("Authentication error:", error);
loginStore.setToken(null); loginStore.setToken(null);
@@ -50,21 +58,25 @@ import events from '@/utils/Events.js';
socket.on("connect", () => { socket.on("connect", () => {
interuptionMessage.value = null; interuptionMessage.value = null;
globalStore.actualPlaylistId = null;
globalStore.setLoading(true); globalStore.setLoading(true);
}); });
socket.on("connect_error", (error) => { socket.on("connect_error", (error) => {
interuptionMessage.value = "Erreur de connexion au serveur : " + error.message; interuptionMessage.value = "Erreur de connexion au serveur : " + error.message;
globalStore.actualPlaylistId = null;
tryReconnect(); tryReconnect();
}); });
socket.on("error", () => { socket.on("error", () => {
interuptionMessage.value = "Erreur de connexion au serveur, veuillez réessayer plus tard"; interuptionMessage.value = "Erreur de connexion au serveur, veuillez réessayer plus tard";
globalStore.actualPlaylistId = null;
tryReconnect(); tryReconnect();
}) })
socket.on("disconnect", () => { socket.on("disconnect", () => {
interuptionMessage.value = "Déconnecté du serveur"; interuptionMessage.value = "Déconnecté du serveur";
globalStore.actualPlaylistId = null;
tryReconnect(); tryReconnect();
}) })

View File

@@ -29,6 +29,40 @@ export function getReadableDuration(duration) {
return max return max
} }
// Get hh:mm:ss format
export function getVideoDuration(duration) {
var max = ""
duration *= 1000;
const maxhours = Math.floor(duration / 3600000);
var maxmin = Math.trunc(duration / 60000) - (Math.floor(duration / 60000 / 60) * 60);
var maxsec = Math.floor(duration / 1000) - (Math.floor(duration / 1000 / 60) * 60);
if (maxsec < 10) {
maxsec = `0${maxsec}`;
}
if(maxhours != 0) {
if (maxmin < 10) {
maxmin = `0${maxmin}`;
}
max = maxhours + ":" + maxmin + ":" + maxsec
} else {
max = maxmin + ":" + maxsec
}
return max
}
export function getSecondsDuration(duration) { export function getSecondsDuration(duration) {
// Duration is in format hh:mm:ss and can be just m:ss or mm:ss // Duration is in format hh:mm:ss and can be just m:ss or mm:ss
var durationArray = duration.split(":"); var durationArray = duration.split(":");
@@ -42,3 +76,4 @@ export function getSecondsDuration(duration) {
} }
return seconds; return seconds;
} }

View File

@@ -2,39 +2,36 @@
<SocketEnvironment> <SocketEnvironment>
<div class="container"> <div class="container">
<Search class="search"/> <Search class="search"/>
<div class="left-side"> <div :class="'left-side' + (globalStore.isViewShowing ? ' hide' : '')">
<GuildHeader class="guildheader"/> <GuildHeader class="guildheader"/>
<Box class="playlist"> <Dispatcher class="dispatcher"/>
<Button @click="router.push('/servers')">Choisir un serveur</Button>
<Button @click="router.push('/terms')">Terms</Button>
<Button @click="router.push('/privacy')">Privacy</Button>
<Button @click="IORequest('/MOD/USERS/BAN', null, '582966873201704960')">Bannir Raphixscrap_</Button>
<Button @click="globalStore.toogleTheme()">Changer le thème</Button>
<p>{{ guildId }}</p>
</Box>
<Account class="account"/> <Account class="account"/>
</div> </div>
<div class="queue"></div> <Box class="queue" padding="closed" ><Queue/></Box>
<View class="view"></View> <View :class="'view' + (!globalStore.isViewShowing ? ' hide' : '')"></View>
<div class="player"></div> <Player class="player"/>
</div> </div>
</SocketEnvironment> </SocketEnvironment>
</template> </template>
<script setup> <script setup>
import Box from '@/components/UI/Box.vue';
import Button from '@/components/UI/Button.vue';
import SocketEnvironment from '@/utils/SocketEnvironment.vue'; import SocketEnvironment from '@/utils/SocketEnvironment.vue';
import { watch } from 'vue'; import { watch } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useGlobalStore } from '@/stores/globalStore'; import { useGlobalStore } from '@/stores/globalStore';
import { useUserStore } from '@/stores/userStore'; import { useUserStore } from '@/stores/userStore';
import { IOListener, IORequest } from '@/utils/IORequest'; import { IORequest, IOListener } from '@/utils/IORequest';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import Account from '@/components/Layout/Account.vue'; import Account from '@/components/Layout/Account.vue';
import events from '@/utils/Events.js'; import events from '@/utils/Events.js';
import GuildHeader from '@/components/Layout/GuildHeader.vue'; import GuildHeader from '@/components/Layout/GuildHeader.vue';
import Search from '@/components/Layout/Search.vue'; import Search from '@/components/Layout/Search.vue';
import View from '@/components/Layout/View.vue'; import View from '@/components/Layout/View.vue';
import Dispatcher from '@/components/Widget/Dispatcher.vue';
import Queue from '@/components/Layout/Queue.vue';
import { loadLogic, updateChannel} from '@/utils/Logic';
import Player from '@/components/Layout/Player.vue';
import Box from '@/components/UI/Box.vue';
import { socket } from '@/socket';
const props = defineProps({ const props = defineProps({
guildId: { guildId: {
@@ -44,6 +41,8 @@ const props = defineProps({
}); });
loadLogic()
const guildId = props.guildId; const guildId = props.guildId;
if (!guildId) { if (!guildId) {
globalStore.setLastGuild(null); globalStore.setLastGuild(null);
@@ -61,11 +60,17 @@ watch(() => globalStore.isLoading, (value, oldValue) => {
}) })
onMounted(() => { onMounted(() => {
globalStore.actualPlaylistId = null;
if(!globalStore.isLoading) { if(!globalStore.isLoading) {
loadInteface(); loadInteface();
} }
IOListener("/CHANNEL/UPDATE", () => {
updateChannel()
})
}); });
events.on("UPDATE", () => { events.on("UPDATE", () => {
checkGuildAvailability(); checkGuildAvailability();
}); });
@@ -88,6 +93,7 @@ function checkGuildAvailability() {
if(response === true) { if(response === true) {
console.log("Successfully joined guild:", guildId); console.log("Successfully joined guild:", guildId);
events.emit("GUILD_JOINED"); events.emit("GUILD_JOINED");
updateChannel();
} else { } else {
console.error("Failed to join guild:", response); console.error("Failed to join guild:", response);
globalStore.setLastGuild(null); globalStore.setLastGuild(null);
@@ -107,14 +113,17 @@ function checkGuildAvailability() {
@media screen and (max-width: 768px), screen and (max-height: 607px) { @media screen and (max-width: 768px), screen and (max-height: 607px) {
.container { .container {
display: flex; display: flex;
gap: 15px; justify-content: space-between;
flex-direction: column; flex-direction: column;
padding: 0 !important;
} }
.left-side { .left-side {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1;
gap: 15px; gap: 15px;
padding: 15px !important;
} }
.guildheader { .guildheader {
@@ -125,8 +134,34 @@ function checkGuildAvailability() {
order: 1; order: 1;
} }
.playlist { .dispatcher {
order: 2; order: 2;
flex: 1;
}
.hide {
display: none !important;
}
.view {
flex: 1;
margin: 15px;
animation: unfold 0.5s ease-in-out;
}
.account {
display: none;
}
.search {
margin: 15px;
margin-bottom: 0;
}
.queue {
display: none;
} }
} }
@@ -157,10 +192,14 @@ function checkGuildAvailability() {
height: 100%; height: 100%;
} }
.playlist { .dispatcher {
flex: 1; flex: 1;
} }
.placeHolderView {
display: none;
}
} }
@media screen and (min-width: 1281px) { @media screen and (min-width: 1281px) {
@@ -185,24 +224,30 @@ function checkGuildAvailability() {
height: 100%; height: 100%;
} }
.playlist { .dispatcher {
flex: 1; flex: 1;
} }
.placeHolderView {
display: none;
}
.queue {
display: flex;
}
} }
.container { .container {
padding: 15px; padding: 15px;
width: 100%; width: 100vw;
} }
.queue, .view, .player {
background-color: var(--secondary);
border-radius: 10px;
}
.view {
display: flex;
}
</style> </style>

View File

@@ -24,7 +24,7 @@ import Error from '@/components/UI/Error.vue';
import Info from '@/components/UI/Info.vue'; import Info from '@/components/UI/Info.vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useLoginStore } from '@/stores/loginStore'; import { useLoginStore } from '@/stores/loginStore';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch, onMounted } from 'vue';
import { socket } from '@/socket.js'; import { socket } from '@/socket.js';
import { useGlobalStore } from '@/stores/globalStore'; import { useGlobalStore } from '@/stores/globalStore';
import Success from '@/components/UI/Success.vue'; import Success from '@/components/UI/Success.vue';
@@ -133,6 +133,9 @@ socket.on("connect_error", (error) => {
}); });
onMounted(() => {
document.title = "Connexion - Subsonics";
});
</script> </script>

View File

@@ -20,6 +20,7 @@ import DefaultSplash from '@/components/UI/DefaultSplash.vue';
import Button from '@/components/UI/Button.vue' import Button from '@/components/UI/Button.vue'
import Box from '@/components/UI/Box.vue'; import Box from '@/components/UI/Box.vue';
import { socket } from '@/socket'; import { socket } from '@/socket';
import { onMounted } from 'vue';
defineProps({ defineProps({
message: { message: {
@@ -32,6 +33,9 @@ if(socket.connected) {
socket.disconnect(); socket.disconnect();
} }
onMounted(() => {
document.title = "Erreur - Subsonics";
});
</script> </script>
<style scoped> <style scoped>

View File

@@ -3,6 +3,11 @@ import ReturnHomeButton from '@/components/Widget/ReturnHomeButton.vue';
import DefaultSplash from '@/components/UI/DefaultSplash.vue'; import DefaultSplash from '@/components/UI/DefaultSplash.vue';
import Box from '@/components/UI/Box.vue'; import Box from '@/components/UI/Box.vue';
import Button from '@/components/UI/Button.vue'; import Button from '@/components/UI/Button.vue';
import { onMounted } from 'vue';
onMounted(() => {
document.title = "Politique de confidentialité - Subsonics";
});
</script> </script>
<template> <template>

View File

@@ -7,12 +7,17 @@ import { useGlobalStore } from '@/stores/globalStore';
import { useUserStore } from '@/stores/userStore'; import { useUserStore } from '@/stores/userStore';
import Button from '@/components/UI/Button.vue'; import Button from '@/components/UI/Button.vue';
import ReturnHomeButton from '@/components/Widget/ReturnHomeButton.vue'; import ReturnHomeButton from '@/components/Widget/ReturnHomeButton.vue';
import { ref } from 'vue'; import { ref, onMounted } from 'vue';
import ServerListItem from '@/components/Widget/Server/ServerListItem.vue'; import ServerListItem from '@/components/Widget/Server/ServerListItem.vue';
import Info from '@/components/UI/Info.vue'; import Info from '@/components/UI/Info.vue';
import Account from '@/components/Layout/Account.vue'; import Account from '@/components/Layout/Account.vue';
const globalStore = useGlobalStore(); const globalStore = useGlobalStore();
onMounted(() => {
document.title = "Mes Serveurs - Subsonics";
});
const userStore = useUserStore(); const userStore = useUserStore();
console.log("Last route:", globalStore.lastRoute); console.log("Last route:", globalStore.lastRoute);
const router = useRouter(); const router = useRouter();
@@ -37,6 +42,7 @@ function inviteSubsonics() {
window.open(botInviteUrl.value, '_blank', `popup,width=600,height=600,left=${(window.innerWidth - 600) / 2},top=${(window.innerHeight - 600) / 2}`); window.open(botInviteUrl.value, '_blank', `popup,width=600,height=600,left=${(window.innerWidth - 600) / 2},top=${(window.innerHeight - 600) / 2}`);
} }
// Vérifier RaphX pourquoi ca plante ! // Vérifier RaphX pourquoi ca plante !
</script> </script>

View File

@@ -3,6 +3,11 @@ import ReturnHomeButton from '@/components/Widget/ReturnHomeButton.vue';
import DefaultSplash from '@/components/UI/DefaultSplash.vue'; import DefaultSplash from '@/components/UI/DefaultSplash.vue';
import Box from '@/components/UI/Box.vue'; import Box from '@/components/UI/Box.vue';
import Button from '@/components/UI/Button.vue'; import Button from '@/components/UI/Button.vue';
import { onMounted } from 'vue';
onMounted(() => {
document.title = "Conditions d'utilisation - Subsonics";
});
</script> </script>
<template> <template>