create initial onboarding draft

This commit is contained in:
cwilvx
2025-09-13 03:25:24 +03:00
parent cf2d9537ff
commit 60e557aefd
14 changed files with 1092 additions and 88 deletions

View File

@@ -1,9 +1,10 @@
<template>
<ContextMenu />
<Modal />
<Notification />
<ContextMenu v-if="!hideUI" />
<Modal v-if="!hideUI" />
<Notification v-if="!hideUI" />
<div id="drag-img" class="ellip2" style=""></div>
<section
v-if="!hideUI"
id="app-grid"
:class="{
useSidebar: settings.use_sidebar && xl,
@@ -27,153 +28,187 @@
<BottomBar />
<!-- <BubbleManager /> -->
</section>
<div v-else id="noui">
<BalancerProvider>
<RouterView />
</BalancerProvider>
</div>
</template>
<script setup lang="ts">
// @libraries
import { vElementSize } from "@vueuse/components";
import { onStartTyping } from "@vueuse/core";
import { onMounted, Ref, ref } from "vue";
import { useRouter } from "vue-router";
import { BalancerProvider } from "vue-wrap-balancer";
import { vElementSize } from '@vueuse/components'
import { onStartTyping } from '@vueuse/core'
import { onBeforeMount, onMounted, Ref, ref } from 'vue'
import { useRouter } from 'vue-router'
import { BalancerProvider } from 'vue-wrap-balancer'
// @stores
import useAuth from "@/stores/auth";
import { content_height, content_width, isMobile, resizer_width, updateCardWidth } from "@/stores/content-width";
import useLyrics from "@/stores/lyrics";
import useModal from "@/stores/modal";
import useQueue from "@/stores/queue";
import useSettings from "@/stores/settings";
import useTracker from "@/stores/tracker";
import useAuth from '@/stores/auth'
import { content_height, content_width, isMobile, resizer_width, updateCardWidth } from '@/stores/content-width'
import useLyrics from '@/stores/lyrics'
import useModal from '@/stores/modal'
import useQueue from '@/stores/queue'
import useSettings from '@/stores/settings'
import useTracker from '@/stores/tracker'
// @utils
import handleShortcuts from "@/helpers/useKeyboard";
import { xl, xxl } from "./composables/useBreakpoints";
import handleShortcuts from '@/helpers/useKeyboard'
import { xl, xxl } from './composables/useBreakpoints'
// @small-components
import ContextMenu from "@/components/ContextMenu.vue";
import Modal from "@/components/modal.vue";
import Notification from "@/components/Notification.vue";
import ContextMenu from '@/components/ContextMenu.vue'
import Modal from '@/components/modal.vue'
import Notification from '@/components/Notification.vue'
// @app-grid-components
import BottomBar from "@/components/BottomBar/BottomBar.vue";
import LeftSidebar from "@/components/LeftSidebar/index.vue";
import NavBar from "@/components/nav/NavBar.vue";
import RightSideBar from "@/components/RightSideBar/Main.vue";
import BottomBar from '@/components/BottomBar/BottomBar.vue'
import LeftSidebar from '@/components/LeftSidebar/index.vue'
import NavBar from '@/components/nav/NavBar.vue'
import RightSideBar from '@/components/RightSideBar/Main.vue'
import { getAllSettings } from "@/requests/settings";
import { getRootDirs } from "@/requests/settings/rootdirs";
import { getLoggedInUser } from "./requests/auth";
import { getAllSettings } from '@/requests/settings'
import { getRootDirs } from '@/requests/settings/rootdirs'
import { getLoggedInUser } from './requests/auth'
// import BubbleManager from "./components/bubbles/BinManager.vue";
const appcontent: Ref<HTMLLegendElement | null> = ref(null);
const auth = useAuth();
const queue = useQueue();
const modal = useModal();
const lyrics = useLyrics();
const router = useRouter();
const settings = useSettings();
useTracker();
const appcontent: Ref<HTMLLegendElement | null> = ref(null)
const auth = useAuth()
const queue = useQueue()
const modal = useModal()
const lyrics = useLyrics()
const router = useRouter()
const settings = useSettings()
const hideUI = ref(false)
useTracker()
handleShortcuts(useQueue, useModal);
handleShortcuts(useQueue, useModal)
router.afterEach(() => {
(document.getElementById("acontent") as HTMLElement).scrollTo(0, 0);
});
const acontent = document.getElementById('acontent') as HTMLElement
if (acontent) {
acontent.scrollTo(0, 0)
}
})
onStartTyping(() => {
const elem = document.getElementById("globalsearch") as HTMLInputElement;
elem.focus();
elem.value = "";
});
const elem = document.getElementById('globalsearch') as HTMLInputElement
elem.focus()
elem.value = ''
})
function getContentSize() {
const elem = document.getElementById("acontent") as HTMLElement;
const elem = document.getElementById('acontent') as HTMLElement
return {
width: elem.offsetWidth,
height: elem.offsetHeight,
};
}
}
function updateContentElemSize({ width, height }: { width: number; height: number }) {
// 1572 is the maxwidth of the #acontent. see app-grid.scss > $maxwidth
const elem_width = appcontent.value?.offsetWidth || 0;
const elem_width = appcontent.value?.offsetWidth || 0
content_width.value = elem_width;
content_height.value = height;
content_width.value = elem_width
content_height.value = height
resizer_width.value = elem_width;
updateCardWidth();
resizer_width.value = elem_width
updateCardWidth()
}
function handleRootDirsPrompt() {
getRootDirs().then(dirs => {
if (dirs.length === 0) {
modal.showRootDirsPromptModal();
modal.showRootDirsPromptModal()
} else {
settings.setRootDirs(dirs);
settings.setRootDirs(dirs)
}
});
})
}
const getCookieValue = (name: string) => document.cookie.match('(^|;)\\s*' + name + '\\s*=\\s*([^;]+)')?.pop() || ''
onMounted(async () => {
const { width, height } = getContentSize();
updateContentElemSize({ width, height });
const res = await getLoggedInUser();
if (res.status == 200) {
auth.setUser(res.data);
} else {
return;
if (hideUI.value) {
return
}
settings.initializeVolume();
const { width, height } = getContentSize()
updateContentElemSize({ width, height })
handleRootDirsPrompt();
const res = await getLoggedInUser()
if (res.status == 200) {
auth.setUser(res.data)
} else {
return
}
settings.initializeVolume()
handleRootDirsPrompt()
getAllSettings()
.then(({ settings: data }) => {
settings.mapDbSettings(data);
settings.mapDbSettings(data)
})
.then(() => {
if (queue.currenttrack && !settings.use_lyrics_plugin) {
lyrics.checkExists(queue.currenttrack.filepath, queue.currenttrack.trackhash);
lyrics.checkExists(queue.currenttrack.filepath, queue.currenttrack.trackhash)
}
});
});
})
})
onBeforeMount(() => {
const onboardingComplete = getCookieValue('onboarding_complete')
console.log('onboardingComplete', onboardingComplete)
if (!onboardingComplete) {
hideUI.value = true
router.push({
name: Routes.Onboarding,
})
}
})
</script>
<script lang="ts">
// Detect OS & browser agents and add class
import { defineComponent } from "vue";
import usePlayer from "./composables/usePlayer";
import { defineComponent } from 'vue'
import usePlayer from './composables/usePlayer'
import { Routes } from './router'
export default defineComponent({
name: "OsAndBrowserSpecificContent",
name: 'OsAndBrowserSpecificContent',
mounted() {
this.applyClassBasedOnAgent();
this.applyClassBasedOnAgent()
},
methods: {
applyClassBasedOnAgent() {
const userAgent = navigator.userAgent;
const isWindows = /Win/.test(userAgent);
const isLinux = /Linux/.test(userAgent) && !/Android/.test(userAgent);
const isChrome = /Chrome/.test(userAgent) && /Google Inc/.test(navigator.vendor);
const userAgent = navigator.userAgent
const isWindows = /Win/.test(userAgent)
const isLinux = /Linux/.test(userAgent) && !/Android/.test(userAgent)
const isChrome = /Chrome/.test(userAgent) && /Google Inc/.test(navigator.vendor)
if ((isWindows || isLinux) && isChrome) {
document.documentElement.classList.add("designatedOS");
document.documentElement.classList.add('designatedOS')
} else {
document.documentElement.classList.add("otherOS");
document.documentElement.classList.add('otherOS')
}
},
},
});
})
</script>
<style lang="scss">
@import "./assets/scss/mixins.scss";
@import './assets/scss/mixins.scss';
.designatedOS .r-sidebar {
&::-webkit-scrollbar {
display: none;
}
}
#noui {
height: 100vh;
width: 100vw;
}
</style>

4
src/assets/icons/add.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg viewBox="0 0 28 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M14.105 26.21C20.7369 26.21 26.2121 20.7273 26.2121 14.105C26.2121 7.47312 20.7273 2 14.0954 2C7.47523 2 2 7.47312 2 14.105C2 20.7273 7.48484 26.21 14.105 26.21ZM14.105 23.8255C8.71085 23.8255 4.39412 19.4991 4.39412 14.105C4.39412 8.71085 8.70124 4.38452 14.0954 4.38452C19.4895 4.38452 23.8276 8.71085 23.8276 14.105C23.8276 19.4991 19.4991 23.8255 14.105 23.8255Z" fill="currentColor"/>
<path d="M8.68359 14.1029C8.68359 14.7383 9.13265 15.1819 9.78304 15.1819H12.9963V18.4048C12.9963 19.0456 13.4496 19.5043 14.085 19.5043C14.7321 19.5043 15.1929 19.0552 15.1929 18.4048V15.1819H18.4179C19.0586 15.1819 19.5152 14.7383 19.5152 14.1029C19.5152 13.4558 19.0586 12.995 18.4179 12.995H15.1929V9.78167C15.1929 9.12917 14.7321 8.67261 14.085 8.67261C13.4496 8.67261 12.9963 9.12917 12.9963 9.78167V12.995H9.78304C9.13265 12.995 8.68359 13.4558 8.68359 14.1029Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 979 B

View File

@@ -1,4 +1,4 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M6.84421 24.8972H21.0295C23.5685 24.8972 24.8737 23.5919 24.8737 21.0914V6.82921C24.8737 4.32656 23.5685 3.02344 21.0295 3.02344H6.84421C4.31484 3.02344 3 4.31695 3 6.82921V21.0914C3 23.6016 4.31484 24.8972 6.84421 24.8972Z" fill="white"/>
<path d="M12.6617 19.7301C12.219 19.7301 11.8571 19.5387 11.5314 19.1137L8.65744 15.6204C8.45002 15.3523 8.34033 15.0818 8.34033 14.7848C8.34033 14.1879 8.81588 13.7037 9.42033 13.7037C9.7822 13.7037 10.0611 13.8281 10.3646 14.2148L12.6212 17.0827L17.4669 9.32416C17.7233 8.92409 18.0554 8.71338 18.4225 8.71338C18.9981 8.71338 19.5376 9.12401 19.5376 9.73807C19.5376 10.0085 19.4033 10.2949 19.232 10.5642L13.7474 19.1053C13.4802 19.5152 13.0982 19.7301 12.6617 19.7301Z" fill="blue"/>
<svg viewBox="0 0 28 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M6.84421 24.8972H21.0295C23.5685 24.8972 24.8737 23.5919 24.8737 21.0914V6.82921C24.8737 4.32656 23.5685 3.02344 21.0295 3.02344H6.84421C4.31484 3.02344 3 4.31695 3 6.82921V21.0914C3 23.6016 4.31484 24.8972 6.84421 24.8972Z" fill="transparent"/>
<path d="M12.6617 19.7301C12.219 19.7301 11.8571 19.5387 11.5314 19.1137L8.65744 15.6204C8.45002 15.3523 8.34033 15.0818 8.34033 14.7848C8.34033 14.1879 8.81588 13.7037 9.42033 13.7037C9.7822 13.7037 10.0611 13.8281 10.3646 14.2148L12.6212 17.0827L17.4669 9.32416C17.7233 8.92409 18.0554 8.71338 18.4225 8.71338C18.9981 8.71338 19.5376 9.12401 19.5376 9.73807C19.5376 10.0085 19.4033 10.2949 19.232 10.5642L13.7474 19.1053C13.4802 19.5152 13.0982 19.7301 12.6617 19.7301Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 847 B

After

Width:  |  Height:  |  Size: 839 B

View File

@@ -0,0 +1,4 @@
<svg viewBox="0 0 28 28" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M14.105 26.21C20.7369 26.21 26.2121 20.7273 26.2121 14.105C26.2121 7.47312 20.7273 2 14.0954 2C7.47523 2 2 7.47312 2 14.105C2 20.7273 7.48484 26.21 14.105 26.21ZM14.105 23.8255C8.71085 23.8255 4.39412 19.4991 4.39412 14.105C4.39412 8.71085 8.70124 4.38452 14.0954 4.38452C19.4895 4.38452 23.8276 8.71085 23.8276 14.105C23.8276 19.4991 19.4991 23.8255 14.105 23.8255Z" fill="currentColor"/>
<path d="M9.64882 15.215H18.5527C19.2532 15.215 19.7386 14.8023 19.7386 14.1263C19.7386 13.4408 19.2725 13.0184 18.5527 13.0184H9.64882C8.92906 13.0184 8.45117 13.4408 8.45117 14.1263C8.45117 14.8023 8.94828 15.215 9.64882 15.215Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 741 B

View File

@@ -75,7 +75,7 @@ button {
font-family: "SF Compact Display", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
font-size: 0.9rem !important;
font-weight: 700;
font-weight: 500;
line-height: 1.2;
color: inherit;
border-radius: $small;
@@ -83,9 +83,9 @@ button {
display: flex;
align-items: center;
justify-content: center;
height: 2.25rem;
padding: 0 $small;
height: 2rem;
transition: background-color 0.2s ease-out, color 0.2s ease-out, border 0.2s ease-out;
padding: $small 1rem;
background-color: $gray4;
cursor: pointer;

View File

@@ -0,0 +1,152 @@
<template>
<div class="account">
<div class="avatarbox rounded-sm">
<!-- <Avatar :name="username"/> -->
<!-- <LogoSvg /> -->
</div>
<form class="createadmin" @submit.prevent="createAccount">
<div>
<div class="heading">Create admin account</div>
<div class="description">This account will be used to manage your server.</div>
</div>
<br />
<div class="form">
<div class="names">
<label for="username">Username</label>
<Input :placeholder="username" input-id="username" required @input="input => (username = input)" />
</div>
<div class="passwords">
<div class="names">
<label for="password">Password</label>
<Input
:placeholder="password"
type="password"
input-id="password"
required
@input="input => (password = input)"
/>
</div>
<div class="names">
<label for="confirmPassword">Confirm Password</label>
<Input
:placeholder="confirmPassword"
type="password"
input-id="confirmPassword"
required
@input="input => (confirmPassword = input)"
/>
</div>
</div>
</div>
<button class="btn-continue">Create account</button>
</form>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { addNewUser } from '@/requests/auth'
import Input from '@/components/shared/Input.vue'
const username = ref('admin')
const password = ref('✶✶✶✶✶✶✶✶')
const confirmPassword = ref('✶✶✶✶✶✶✶✶')
const emit = defineEmits(['accountCreated', 'error'])
function validatePassword() {
// check if password is at least 8 characters
if (password.value.length < 8) {
return emit('error', 'Password must be at least 8 characters')
}
// check if password and confirm password match
if (password.value !== confirmPassword.value) {
return emit('error', 'Passwords do not match')
}
emit('error', '')
return true
}
async function createAccount() {
if (!validatePassword()) {
return
}
const response = await addNewUser({
username: username.value,
password: password.value,
})
console.log(response)
if (response.status === 200) {
emit('accountCreated', response.data.userhome)
}
}
onMounted(() => {
// focus on username input
document.getElementById('username')?.focus()
})
</script>
<style lang="scss">
.account {
display: grid;
grid-template-columns: 1fr 1.25fr;
gap: 1.5rem;
width: 100%;
margin: 0 $medium 0 $medium;
.avatarbox {
width: 100%;
height: 100%;
// background gradient
background: linear-gradient(37deg, $pink, $purple);
display: flex;
// margin-left: $medium;
align-items: center;
justify-content: center;
padding: $small;
svg {
transform: scale(2);
}
background: url('https://images.unsplash.com/photo-1632516643720-e7f5d7d6ecc9?q=80&w=2022&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D')
no-repeat center center;
background-size: cover;
}
.passwords {
display: flex;
flex-direction: row;
gap: 1rem;
margin-top: $small;
}
.createadmin {
width: 100%;
height: 100%;
.heading {
margin-bottom: $smaller;
}
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
justify-content: flex-end;
label {
font-size: 0.9rem;
font-weight: 600;
color: rgb(210, 210, 210);
}
}
}
</style>

View File

@@ -0,0 +1,354 @@
<template>
<div class="file-picker">
<div class="input-container">
<div class="head">
<button class="btn-back" @click="$emit('cancel')">
<ArrowLeftSvg height="1.2rem" />
</button>
<div>
<div class="breadcrumb">
<div
v-for="path in subPaths"
:key="path.path"
class="bitem"
@click="() => fetchFolders(path.path)"
>
{{ path.name }}
</div>
</div>
</div>
<span
><button
class="btn-finish"
@click="
$emit(
'submitDirs',
Array.from(selectedFolders.values()).map(index => folders[index].path)
)
"
>
Continue
</button></span
>
</div>
</div>
<div class="folders">
<div
v-for="(folder, index) in renderedFolders"
:key="folder.path"
class="folder"
:class="{
selected: selectedFolders.has(index),
'selected-first': selectedFolders.has(index) && isFirstSelected(index),
'selected-last': selectedFolders.has(index) && isLastSelected(index),
}"
@click.exact="handleSelect(index, false, false)"
@click.meta="handleSelect(index, false, true)"
@click.ctrl="handleSelect(index, false, true)"
@click.shift="handleSelect(index, true, false)"
@dblclick="fetchFolders(folder.path)"
>
<FolderSvg />
<span>{{ folder.name }}</span>
</div>
</div>
<div class="help rounded-sm">
<div class="help-content">
<InfoSvg />
<span>Use (/Ctrl or Shift) + Click to select multiple folders</span>
</div>
<span>{{ selectedFolders.size }} Selected</span>
</div>
</div>
</template>
<!-- TODO: Handle duplicates on final root dirs -->
<!-- TODO: Clear Errors on setting root dirs -->
<!-- TODO: Work on breadcrumb -->
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import Input from '../shared/Input.vue'
import { getFolders } from '@/requests/settings/rootdirs'
import FolderSvg from '@/assets/icons/folder.svg'
import AddSvg from '@/assets/icons/add.svg'
import SubtractSvg from '@/assets/icons/subtract.svg'
import ArrowLeftSvg from '@/assets/icons/arrow.svg'
import InfoSvg from '@/assets/icons/info.svg'
import { Folder } from '@/interfaces'
import { createSubPaths } from '@/utils'
const props = defineProps<{
userhome: string
}>()
defineEmits<{
(e: 'submitDirs', dirs: string[]): void
(e: 'cancel'): void
}>()
const currentPath = ref<string>('')
const folders = ref<Folder[]>([])
const subPaths = computed(() => {
return createSubPaths(currentPath.value, '')[1]
})
const renderedFolders = computed(() => {
const first2 = [...folders.value]
if (first2.length >= 2) {
first2[0].name = '↑'
first2[1].name = '. (this folder)'
}
return first2
})
const lastSelectedIndex = ref<number>(-1)
const selectedFolders = ref<Set<number>>(new Set())
function isFirstSelected(index: number): boolean {
if (!selectedFolders.value.has(index)) return false
const previousIndex = index - 1
return previousIndex < 0 || !selectedFolders.value.has(previousIndex)
}
function isLastSelected(index: number): boolean {
if (!selectedFolders.value.has(index)) return false
const nextIndex = index + 1
return nextIndex >= renderedFolders.value.length || !selectedFolders.value.has(nextIndex)
}
async function fetchFolders(folder: string) {
const results = await getFolders(folder)
folders.value = results
currentPath.value = folder
selectedFolders.value.clear()
lastSelectedIndex.value = -1
}
function handleSelect(index: number, shift: boolean, ctrl: boolean) {
// INFO: Handle shift - range selection
if (shift) {
// INFO: Handle selection of parent and current folder
// INFO: .. and . folder should only be selected alone
// INFO: Handle selection from ../. to folder N
if (lastSelectedIndex.value <= 1) {
lastSelectedIndex.value = 2
selectedFolders.value = new Set([2])
}
// INFO: Handle selection from folder N to parent
if (index <= 1) {
selectedFolders.value = new Set([lastSelectedIndex.value])
index = 2
}
// Select range from last selected to current
const start = Math.min(lastSelectedIndex.value, index)
const end = Math.max(lastSelectedIndex.value, index)
for (let i = start; i <= end; i++) {
selectedFolders.value.add(i)
}
lastSelectedIndex.value = index
return
}
// INFO: Handle ctrl - toggle selection
if (ctrl) {
if (index <= 1) {
selectedFolders.value = new Set([index])
lastSelectedIndex.value = index
return
}
if (selectedFolders.value.has(0) || selectedFolders.value.has(1)) {
selectedFolders.value = new Set([index])
lastSelectedIndex.value = index
return
}
if (selectedFolders.value.has(index)) {
selectedFolders.value.delete(index)
} else {
selectedFolders.value.add(index)
}
lastSelectedIndex.value = index
return
}
// INFO: Handle regular click - single selection
selectedFolders.value.clear()
selectedFolders.value.add(index)
lastSelectedIndex.value = index
}
onMounted(async () => {
currentPath.value = props.userhome
await fetchFolders(props.userhome)
})
</script>
<style lang="scss">
.file-picker {
width: 100%;
height: 100%;
display: grid;
grid-template-columns: 1fr;
grid-template-rows: max-content 1fr max-content;
position: relative;
gap: $small;
.breadcrumb {
display: flex;
flex-direction: row;
gap: $small;
font-weight: 500;
font-size: 0.8rem;
.bitem {
color: rgb(155, 154, 154);
position: relative;
cursor: pointer;
// outline: solid 1px;
}
.bitem:last-child {
color: $blue;
cursor: default;
&::after {
display: none;
}
}
.bitem::after {
content: '/';
margin-right: $small;
color: $gray2;
font-size: 0.8rem;
font-weight: 500;
cursor: default;
position: absolute;
// center the before vertical
top: 50%;
right: -0.9rem;
transform: translateY(-50%);
}
}
.head {
margin-bottom: $small;
font-size: 1rem;
font-weight: 600;
display: grid;
grid-template-columns: max-content 1fr max-content;
align-items: center;
gap: 1rem;
button {
font-size: 0.8rem;
}
}
.help {
display: flex;
align-items: center;
justify-content: space-between;
// margin: $small $small;
font-size: 0.65rem;
.help-content {
display: flex;
align-items: center;
gap: $small;
}
background-color: transparent;
padding: $small;
margin-bottom: -$small;
color: $gray1;
svg {
height: 0.75rem;
}
}
.folder:nth-child(2),
.folder:first-child {
font-size: 0.7rem;
color: $gray1;
svg {
color: #fff;
}
}
.folder:nth-child(2) {
font-style: italic;
}
}
.folders {
display: flex;
flex-direction: column;
height: 100%;
width: calc(100% + $small);
overflow-y: auto;
margin-left: $smaller;
.folder {
display: grid;
grid-template-columns: max-content 1fr max-content;
align-items: center;
gap: $small;
padding: $small;
cursor: default;
border-radius: $smaller;
font-size: 0.9rem;
font-weight: 500;
// disable select
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
width: calc(100% - 1rem);
&:hover {
background-color: $gray5;
.action {
opacity: 1;
}
}
}
.folder.selected {
background-color: $darkblue;
border-radius: 0;
}
.folder.selected-first {
border-top-left-radius: $small;
border-top-right-radius: $small;
}
.folder.selected-last {
border-bottom-left-radius: $small;
border-bottom-right-radius: $small;
}
svg {
height: 1.2rem;
}
}
</style>

View File

@@ -0,0 +1,251 @@
<template>
<FilePicker v-if="showFilePicker" :userhome="userhome" @submitDirs="handleSubmitDirs" @cancel="toggleFilePicker" />
<div v-else class="rootdirconfig">
<div class="heading">Configure root directories</div>
<div class="description">Where do you want to look for music?</div>
<br />
<div class="options">
<div class="option" @click="toggleHomeDir">
<div>
<div class="option-title">Home directory</div>
<div class="option-description">
Scan all folders in <span class="userhome">{{ userhome }}</span>
</div>
</div>
<div class="option-selected">
<CheckSvg v-if="homeDirSelected" height="1.75rem" />
</div>
</div>
<br />
<div class="option" @click="toggleFilePicker">
<div>
<div class="option-title">Specific directory</div>
<div class="option-description">
{{
specificDirsSelected
? `${finalRootDirs.length} folder${finalRootDirs.length !== 1 ? 's' : ''} selected`
: 'Select folder to scan for music'
}}
</div>
</div>
<div v-show="specificDirsSelected" class="option-selected">
<span>Add Folders</span>
<CheckSvg v-if="specificDirsSelected" height="1.75rem" />
</div>
</div>
</div>
<br />
<div class="btn-container">
<button class="btn-continue" @click="handleContinue">Continue</button>
</div>
</div>
<div v-if="rootDirs.length > 0 && !homeDirSelected" class="selected-folders rounded-sm">
<div class="heading">{{ finalRootDirs.length }} Selected Folders</div>
<div class="folders">
<div v-for="folder in finalRootDirs" :key="folder" class="folder">
<FolderSvg />
{{ folder.startsWith(userhome) ? folder.replace(userhome, '~') : folder }}
<div class="action" @click="handleRemoveFolder(folder)">
<SubtractSvg />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import FilePicker from './FilePicker.vue'
import CheckSvg from '@/assets/icons/check.filled.svg'
import FolderSvg from '@/assets/icons/folder.svg'
import SubtractSvg from '@/assets/icons/subtract.svg'
// SECTION: Props & Emits
const props = defineProps<{
userhome: string
}>()
const emit = defineEmits<{
(e: 'setRootDirs', dirs: string[]): void
(e: 'error', error: string): void
}>()
// SECTION: Properties
const showFilePicker = ref(false)
const rootDirs = ref<string[]>([])
const finalRootDirs = computed(() => {
if (rootDirs.value.length <= 1) {
return rootDirs.value
}
// Remove duplicates first
const uniqueDirs = [...new Set(rootDirs.value)]
// Sort directories by length (shortest first) to process parents before children
const sortedDirs = uniqueDirs.sort((a, b) => a.length - b.length)
const filteredDirs: string[] = []
for (const dir of sortedDirs) {
// Check if this directory is a child of any already selected directory
const isChild = filteredDirs.some(parentDir => {
// Ensure parent directory ends with '/' for proper path comparison
const normalizedParent = parentDir.endsWith('/') ? parentDir : parentDir + '/'
return dir.startsWith(normalizedParent)
})
// Only add if it's not a child of any parent directory
if (!isChild) {
filteredDirs.push(dir)
}
}
return filteredDirs
})
const homeDirSelected = computed(() => {
return finalRootDirs.value.length == 1 && finalRootDirs.value[0] == props.userhome
})
const specificDirsSelected = computed(() => finalRootDirs.value.length && !homeDirSelected.value)
// SECTION: Handlers
function toggleFilePicker() {
// INFO: Reset root dirs if home dir is selected
if (homeDirSelected.value) {
rootDirs.value = []
}
showFilePicker.value = !showFilePicker.value
}
function toggleHomeDir() {
if (homeDirSelected.value) {
rootDirs.value = []
} else {
rootDirs.value = [props.userhome]
}
}
function handleSubmitDirs(dirs: string[]) {
rootDirs.value.push(...dirs)
emit("error", "")
showFilePicker.value = false
}
function handleRemoveFolder(folder: string) {
rootDirs.value = rootDirs.value.filter(dir => dir !== folder)
}
function handleContinue() {
if (!rootDirs.value.length) {
emit('error', 'Please select a root directory')
return
}
emit('setRootDirs', rootDirs.value)
}
</script>
<style lang="scss">
.rootdirconfig {
width: 30rem;
// text-align: center;
.heading {
margin-bottom: $smaller;
}
}
.option {
padding: 1rem;
border-radius: $small;
background-color: $gray5;
cursor: pointer;
display: grid;
grid-template-columns: 1fr max-content;
gap: 1rem;
align-items: center;
&:hover {
background-color: $gray3;
}
.option-selected {
// width: 1.25rem;
font-size: 0.8rem;
font-weight: 500;
color: $gray1;
svg {
color: $green;
}
display: flex;
align-items: center;
gap: $small;
}
}
.userhome {
font-weight: 500;
font-family: 'SF Mono';
}
.option-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: $smaller;
}
.option-description {
font-size: 0.8rem;
font-weight: 500;
}
.options {
display: flex;
flex-direction: column;
gap: $small;
}
.btn-container {
display: flex;
justify-content: center;
}
.selected-folders {
position: absolute;
top: 34rem;
width: 100%;
background-color: $gray;
padding: $small;
padding-top: 1rem;
.folders {
height: auto;
max-height: 12rem;
overflow-y: auto;
}
.heading {
position: absolute;
top: -2rem;
font-size: 0.8rem;
font-weight: 600;
color: $gray1;
}
// font-size: 0.8rem;
.action {
opacity: 0.5;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
&:hover {
color: $red;
}
}
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<div class="welcome">
<div class="logo"><LogoSvg /></div>
<div class="heading">Welcome to</div>
<div class="appname">Swing Music</div>
<p class="tagline">
You will need to configure your account login details <br />
and root directories to get started.
</p>
<button class="btn-continue" @click="emit('continue')">Get Started</button>
</div>
</template>
<script setup lang="ts">
import LogoSvg from '@/assets/icons/logos/logo-fill.light.svg'
const emit = defineEmits(['continue'])
</script>
<style lang="scss">
.welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $medium;
// center everything
text-align: center;
.logo {
width: 100%;
height: 100%;
}
.appname {
color: $highlight-blue;
font-size: 2.5rem;
font-weight: 700;
// gradient text
background: linear-gradient(to right, $red, $blue, $red);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
// disable selection
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<form class="secretinput" @submit.prevent="$emit('submit', input)">
<div class="left rounded-sm no-scroll">
<input :type="showText ? 'text' : 'password'" v-model="input" @input="() => (showTextManual = true)" />
<input v-model="input" :type="showText ? 'text' : 'password'" @input="() => (showTextManual = true)" />
<button @click.prevent="showTextManual = !showTextManual">
<EyeSvg v-if="showText" />
<EyeSlashSvg v-else />

View File

@@ -2,15 +2,17 @@
<div class="passinput">
<input
:id="props.inputId"
v-model="value"
class="passinput"
:type="type"
:placeholder="placeholder"
:required="required"
@input="$emit('input', ($event.target as HTMLInputElement).value)"
v-model="value"
disabled
/>
<div
class="showpass rounded-sm"
v-if="props.type === 'password'"
class="showpass rounded-sm"
:class="{ show: value.length }"
@click="toggleShowPassword"
>
@@ -30,6 +32,8 @@ const props = defineProps<{
type?: string
placeholder?: string
inputId?: string
required?: boolean
disabled?: boolean
}>()
const value = ref('')

View File

@@ -28,6 +28,7 @@ const StatsView = () => import('@/views/Stats/main.vue')
const MixView = () => import('@/views/MixView.vue')
const MixListView = () => import('@/views/MixListView.vue')
const Collection = () => import('@/views/Collections/Collection.vue')
const Onboarding = () => import('@/views/Onboarding.vue')
const folder = {
path: '/folder/:path',
@@ -208,6 +209,12 @@ const PageView = {
component: Collection,
}
const OnboardingView = {
path: '/onboarding',
name: 'Onboarding',
component: Onboarding,
}
const routes = [
folder,
playlists,
@@ -232,6 +239,7 @@ const routes = [
Mix,
MixList,
PageView,
OnboardingView,
]
const Routes = {
@@ -258,6 +266,7 @@ const Routes = {
Mix: Mix.name,
MixList: MixList.name,
Page: PageView.name,
Onboarding: OnboardingView.name,
}
const router = createRouter({

134
src/views/Onboarding.vue Normal file
View File

@@ -0,0 +1,134 @@
<template>
<div class="onboarding pad-lg rounded-sm">
<component
:is="steps[currentStepIndex].component"
v-bind="steps[currentStepIndex].props ?? {}"
@continue="handleContinue"
@account-created="handleAccountCreated"
@set-root-dirs="handleRootDirs"
@error="handleError"
/>
<div class="progressbar">
<div
v-for="(step, index) in steps"
:key="step.name"
class="dot"
:class="{ active: index === currentStepIndex, complete: index < currentStepIndex }"
>
<div class="dot-inner"></div>
</div>
</div>
<div v-if="errorText" class="errorbox rounded-md">
<ErrorSvg />
<div>{{ errorText }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import ErrorSvg from '@/assets/icons/toast/error.svg'
import Welcome from '@/components/Onboarding/Welcome.vue'
import Account from '@/components/Onboarding/Account.vue'
import RootDirs from '@/components/Onboarding/RootDirs.vue'
const errorText = ref('')
const currentStepIndex = ref(2)
const userhome = ref('/Users/cwilvx')
const steps = [
{ name: 'welcome', component: Welcome },
{ name: 'account', component: Account },
{ name: 'dirconfig', component: RootDirs, props: { userhome: userhome.value } },
{ name: 'finish' },
]
function handleAccountCreated(user_home_path: string) {
userhome.value = user_home_path
currentStepIndex.value++
}
function handleRootDirs(dirs: string[]) {
console.log(dirs)
// rootDirs.value = dirs
}
function handleContinue() {
errorText.value = ''
currentStepIndex.value++
}
function handleError(error: string) {
errorText.value = error
}
</script>
<style lang="scss">
.onboarding {
background-color: $gray;
// border: solid 1px $gray4;
height: 30rem;
width: 50rem;
position: absolute;
top: 25rem;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
// disable selection
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
.btn-continue {
background-color: $blue;
border-radius: 4rem;
padding: 1.25rem 2rem;
}
.progressbar {
display: flex;
gap: $small;
position: absolute;
bottom: -2rem;
left: 50%;
transform: translateX(-50%);
.dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 100%;
background-color: $gray;
}
.dot.active {
background-color: $gray3;
}
}
.errorbox {
position: absolute;
width: 100%;
top: 33.5rem;
min-height: 1rem;
left: 50%;
transform: translateX(-50%);
color: $red;
background-color: $gray;
padding: 1rem;
display: grid;
grid-template-columns: 2rem 1fr;
gap: $small;
align-items: center;
}
}
</style>

View File

@@ -10,6 +10,9 @@ import { nodePolyfills } from 'vite-plugin-node-polyfills'
const path = require("path");
export default defineConfig({
define: {
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false'
},
base: "./",
plugins: [
vue(),
@@ -97,6 +100,8 @@ export default defineConfig({
],
resolve: {
alias: {
util: "util/",
stream: "stream-browserify",
"@": path.resolve(__dirname, "src"),
},
},