add several pages and life wave
7
frontend/package-lock.json
generated
|
|
@ -10,6 +10,7 @@
|
|||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@quasar/extras": "^1.16.4",
|
||||
"gsap": "^3.13.0",
|
||||
"pinia": "^3.0.1",
|
||||
"quasar": "^2.16.0",
|
||||
"vue": "^3.4.18",
|
||||
|
|
@ -3819,6 +3820,12 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/gsap": {
|
||||
"version": "3.13.0",
|
||||
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.13.0.tgz",
|
||||
"integrity": "sha512-QL7MJ2WMjm1PHWsoFrAQH/J8wUeqZvMtHO58qdekHpCfhvhSL4gSiz6vJf5EeMP0LOn3ZCprL2ki/gjED8ghVw==",
|
||||
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -15,23 +15,24 @@
|
|||
"postinstall": "quasar prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^3.0.1",
|
||||
"@quasar/extras": "^1.16.4",
|
||||
"gsap": "^3.13.0",
|
||||
"pinia": "^3.0.1",
|
||||
"quasar": "^2.16.0",
|
||||
"vue": "^3.4.18",
|
||||
"vue-router": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.14.0",
|
||||
"@quasar/app-vite": "^2.1.0",
|
||||
"@vue/eslint-config-prettier": "^10.1.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-plugin-vue": "^9.30.0",
|
||||
"globals": "^15.12.0",
|
||||
"vite-plugin-checker": "^0.9.0",
|
||||
"@vue/eslint-config-prettier": "^10.1.0",
|
||||
"postcss": "^8.4.14",
|
||||
"prettier": "^3.3.3",
|
||||
"@quasar/app-vite": "^2.1.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"postcss": "^8.4.14"
|
||||
"vite-plugin-checker": "^0.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^28 || ^26 || ^24 || ^22 || ^20 || ^18",
|
||||
|
|
|
|||
BIN
frontend/public/images/0_2.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/public/images/0_3.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
frontend/public/images/disco.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
frontend/public/images/entspannung.png
Normal file
|
After Width: | Height: | Size: 278 KiB |
BIN
frontend/public/images/familie.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/public/images/familie2.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
frontend/public/images/feier.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
frontend/public/images/gpt.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
frontend/public/images/grosseltern.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
frontend/public/images/hochzeit.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
frontend/public/images/kindergeburtstag.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
frontend/public/images/kinobesuch.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
frontend/public/images/klasse.png
Normal file
|
After Width: | Height: | Size: 344 KiB |
BIN
frontend/public/images/oma.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
frontend/public/images/pferd.png
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
frontend/public/images/see.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
frontend/public/images/sonntag.png
Normal file
|
After Width: | Height: | Size: 630 KiB |
BIN
frontend/public/images/work.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
11
frontend/public/js/ScrollTrigger.min.js
vendored
Normal file
1
frontend/public/js/ScrollTrigger.min.js.map
Normal file
11
frontend/public/js/gsap.min.js
vendored
Normal file
1
frontend/public/js/gsap.min.js.map
Normal file
|
|
@ -93,6 +93,10 @@ export default defineConfig((/* ctx */) => {
|
|||
plugins: []
|
||||
},
|
||||
|
||||
htmlVariables: {
|
||||
fonts: '<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&family=Montserrat:ital,wght@0,100..900;1,100..900&family=Nunito:ital,wght@0,200..1000;1,200..1000&display=swap" rel="stylesheet">'
|
||||
},
|
||||
|
||||
// animations: 'all', // --- includes all animations
|
||||
// https://v2.quasar.dev/options/animations
|
||||
animations: [],
|
||||
|
|
|
|||
|
|
@ -1 +1,175 @@
|
|||
// app global css in SCSS form
|
||||
// Variables
|
||||
$primary-color: #4f46e5;
|
||||
$gradient-colors: (45deg, #8634f9, #ffab1a, #ff2fa2);
|
||||
$button-radius: 4px;
|
||||
$button-padding: 6px 12px;
|
||||
$tooltip-radius: 4px;
|
||||
$image-size: 80px;
|
||||
|
||||
// Mixins
|
||||
@mixin flex-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
// Global Styles
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: $button-padding;
|
||||
background-color: $primary-color;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: $button-radius;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
// Wave Visualization Styles
|
||||
.visualization-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: calc(100vh - 86px);
|
||||
overflow: hidden;
|
||||
|
||||
}
|
||||
|
||||
.gradient-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
background: linear-gradient(45deg, #8634f9, #ffab1a, #ff2fa2);
|
||||
background-size: 200% 200%;
|
||||
animation: gradientAnimation 20s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes gradientAnimation {
|
||||
0% { background-position: 0% 0%; }
|
||||
25% { background-position: 100% 0%; }
|
||||
50% { background-position: 100% 100%; }
|
||||
75% { background-position: 0% 100%; }
|
||||
100% { background-position: 0% 0%; }
|
||||
}
|
||||
|
||||
.median {
|
||||
position: absolute;
|
||||
top: 54.75%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background-color: rgba(255,255,255,0.3);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.scroll-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.smooth-scroll {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.dot-tooltip {
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
|
||||
.tooltip-background {
|
||||
fill: rgba(0, 0, 0, 0.0);
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
@include flex-center;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.image_container {
|
||||
margin-top: 8px;
|
||||
box-shadow: 0 0 20px 0 rgba(255, 255, 255, 0.25);
|
||||
transition: box-shadow 0.25s ease-in-out;
|
||||
width: $image-size;
|
||||
height: $image-size;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
border: 2px solid white;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 30px 0 rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip-image {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tooltip-title {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 2px;
|
||||
text-align: center;
|
||||
text-wrap: balance;
|
||||
hyphens: auto;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.tooltip-description {
|
||||
font-size: 12px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.tooltip-arrow {
|
||||
width: 1px;
|
||||
height: 30px;
|
||||
background: linear-gradient(to bottom, transparent, rgba(255, 255, 255, 0.5), transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.dot {
|
||||
transition: r 0.2s ease, fill 0.2s ease;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
fill: rgba(255, 255, 255, 0.9);
|
||||
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.8));
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: $tooltip-radius;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,29 +6,22 @@
|
|||
MIIUS <!-- Me You Us -->
|
||||
</q-toolbar-title>
|
||||
|
||||
<q-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
icon="menu"
|
||||
aria-label="Menu"
|
||||
@click="toggleDrawer"
|
||||
/>
|
||||
<!-- Add login status indicator -->
|
||||
<q-chip v-if="isLoggedIn" color="green" text-color="white" icon="check_circle">
|
||||
Logged In
|
||||
</q-chip>
|
||||
<q-chip v-else color="grey" text-color="white" icon="person_off">
|
||||
Not Logged In
|
||||
</q-chip>
|
||||
|
||||
<q-btn flat dense round icon="menu" aria-label="Menu" @click="toggleDrawer" />
|
||||
|
||||
</q-toolbar>
|
||||
</q-header>
|
||||
|
||||
<!-- Off-Canvas Drawer -->
|
||||
<q-drawer
|
||||
v-model="drawer"
|
||||
show-if-above
|
||||
side="right"
|
||||
:width="280"
|
||||
:breakpoint="768"
|
||||
|
||||
class="bg-grey-1"
|
||||
style="overflow: hidden;"
|
||||
>
|
||||
<!-- Off-Canvas Drawer -->
|
||||
<q-drawer v-model="drawer" show-if-above side="right" :width="280" :breakpoint="768" class="bg-grey-1"
|
||||
style="overflow: hidden;">
|
||||
<!-- Sliding Menu Container -->
|
||||
<div class="menu-container" :style="{ transform: `translateX(${slideOffset}px)` }">
|
||||
|
||||
|
|
@ -37,16 +30,28 @@
|
|||
|
||||
<!-- Main Menu List -->
|
||||
<q-list>
|
||||
<q-item clickable v-ripple @click="navigateTo('life-wave')">
|
||||
|
||||
<q-item clickable v-ripple @click="navigateTo('login')" v-if="!isLoggedIn" to="/login">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="login" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Login</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<!-- Move password reset to Account submenu and only show when logged in -->
|
||||
|
||||
<q-item clickable v-ripple @click="navigateTo('wave')" v-if="isLoggedIn" to="/wave">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="line_axis" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Life Wave</q-item-label>
|
||||
<q-item-label>Wave</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="navigateTo('new-entry')">
|
||||
<q-item clickable v-ripple @click="navigateTo('new-entry')" v-if="isLoggedIn">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="add_circle" />
|
||||
</q-item-section>
|
||||
|
|
@ -55,7 +60,7 @@
|
|||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="openSubmenu('my-people', 'My People')">
|
||||
<q-item clickable v-ripple @click="openSubmenu('my-people', 'My People')" v-if="isLoggedIn">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="people" />
|
||||
</q-item-section>
|
||||
|
|
@ -67,7 +72,7 @@
|
|||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="openSubmenu('messages', 'Messages')">
|
||||
<q-item clickable v-ripple @click="openSubmenu('messages', 'Messages')" v-if="isLoggedIn">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="mail" />
|
||||
</q-item-section>
|
||||
|
|
@ -81,7 +86,7 @@
|
|||
|
||||
<q-separator />
|
||||
|
||||
<q-item clickable v-ripple @click="openSubmenu('profile', 'Profile')">
|
||||
<q-item clickable v-ripple @click="openSubmenu('profile', 'Profile')" v-if="isLoggedIn">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="person" />
|
||||
</q-item-section>
|
||||
|
|
@ -93,7 +98,7 @@
|
|||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="openSubmenu('account', 'Account')">
|
||||
<q-item clickable v-ripple @click="openSubmenu('account', 'Account')" v-if="isLoggedIn">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="account_circle" />
|
||||
</q-item-section>
|
||||
|
|
@ -105,21 +110,7 @@
|
|||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
|
||||
|
||||
<q-item clickable v-ripple @click="openSubmenu('login', 'Login')">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="login" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Login</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section side>
|
||||
<q-icon name="chevron_right" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-item clickable v-ripple @click="navigateTo('sign-up')">
|
||||
<q-item clickable v-ripple @click="navigateTo('sign-up')" to="/sign-up" v-if="!isLoggedIn">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="person_add" />
|
||||
</q-item-section>
|
||||
|
|
@ -128,7 +119,7 @@
|
|||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<q-separator />
|
||||
<q-separator />
|
||||
|
||||
<q-item clickable v-ripple @click="openSubmenu('support', 'Need Help?')">
|
||||
<q-item-section avatar>
|
||||
|
|
@ -159,14 +150,7 @@
|
|||
<!-- Second Level Menu Panel -->
|
||||
<div class="menu-panel">
|
||||
<div class="q-pa-md bg-secondary text-white">
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
icon="arrow_back"
|
||||
@click="goBack"
|
||||
class="q-mr-sm"
|
||||
size="sm"
|
||||
/>
|
||||
<q-btn flat round icon="arrow_back" @click="goBack" class="q-mr-sm" size="sm" />
|
||||
<span class="text-h6">{{ currentSubmenuTitle }}</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -288,6 +272,17 @@
|
|||
<q-icon name="chevron_right" />
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
<!-- Add a logout button that only appears when logged in -->
|
||||
<q-item clickable v-ripple @click="logout" v-if="isLoggedIn">
|
||||
<q-item-section avatar>
|
||||
<q-icon name="logout" />
|
||||
</q-item-section>
|
||||
<q-item-section>
|
||||
<q-item-label>Logout</q-item-label>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
|
||||
</q-list>
|
||||
|
||||
<!-- Login Submenu -->
|
||||
|
|
@ -375,14 +370,7 @@
|
|||
<!-- Third Level Menu Panel -->
|
||||
<div class="menu-panel">
|
||||
<div class="q-pa-md bg-accent text-white">
|
||||
<q-btn
|
||||
flat
|
||||
round
|
||||
icon="arrow_back"
|
||||
@click="goBack"
|
||||
class="q-mr-sm"
|
||||
size="sm"
|
||||
/>
|
||||
<q-btn flat round icon="arrow_back" @click="goBack" class="q-mr-sm" size="sm" />
|
||||
<span class="text-h6">{{ currentSubmenuTitle }}</span>
|
||||
</div>
|
||||
|
||||
|
|
@ -504,7 +492,7 @@
|
|||
<q-route-tab to="/people" icon="people" />
|
||||
<q-route-tab to="/entry" icon="add_circle_outline" />
|
||||
<q-route-tab to="/messages" icon="mail" />
|
||||
<q-route-tab to="/profile" icon="person"/>
|
||||
<q-route-tab to="/profile" icon="person" />
|
||||
|
||||
</q-tabs>
|
||||
</q-footer>
|
||||
|
|
@ -513,11 +501,13 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'MultiLevelSlidingMenu',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const drawer = ref(false)
|
||||
const currentRoute = ref('home')
|
||||
const menuLevel = ref(0)
|
||||
|
|
@ -525,6 +515,22 @@ export default {
|
|||
const currentSubmenuTitle = ref('')
|
||||
const menuHistory = ref([])
|
||||
|
||||
// Make isLoggedIn reactive to localStorage changes
|
||||
const isLoggedIn = ref(localStorage.getItem('isLoggedIn') === 'true')
|
||||
|
||||
// Listen for storage changes (when logging in from another tab/component)
|
||||
const updateLoginStatus = () => {
|
||||
isLoggedIn.value = localStorage.getItem('isLoggedIn') === 'true'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('storage', updateLoginStatus)
|
||||
// Also check on route changes
|
||||
router.afterEach(() => {
|
||||
updateLoginStatus()
|
||||
})
|
||||
})
|
||||
|
||||
const slideOffset = computed(() => {
|
||||
return menuLevel.value * -280 // Each level slides 280px (drawer width) to the left
|
||||
})
|
||||
|
|
@ -590,11 +596,21 @@ export default {
|
|||
}
|
||||
}
|
||||
|
||||
// Update the login function
|
||||
const login = () => {
|
||||
isLoggedIn.value = true
|
||||
localStorage.setItem('isLoggedIn', 'true')
|
||||
}
|
||||
|
||||
// Update the logout function
|
||||
const logout = () => {
|
||||
console.log('Logging out...')
|
||||
isLoggedIn.value = false
|
||||
localStorage.setItem('isLoggedIn', 'false')
|
||||
currentRoute.value = 'login'
|
||||
drawer.value = false
|
||||
resetMenu()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -609,7 +625,9 @@ export default {
|
|||
openSubmenu,
|
||||
goBack,
|
||||
navigateTo,
|
||||
logout
|
||||
logout,
|
||||
isLoggedIn,
|
||||
login
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -627,23 +645,34 @@ export default {
|
|||
|
||||
@keyframes gradientAnimation {
|
||||
0% {
|
||||
background-position: 0% 0%; /* Start Links Mitte */
|
||||
background-position: 0% 0%;
|
||||
/* Start Links Mitte */
|
||||
}
|
||||
|
||||
25% {
|
||||
background-position: 100% 0%; /* Oben Rechts */
|
||||
background-position: 100% 0%;
|
||||
/* Oben Rechts */
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 100%; /* Unten Rechts */
|
||||
background-position: 100% 100%;
|
||||
/* Unten Rechts */
|
||||
}
|
||||
|
||||
75% {
|
||||
background-position: 0% 100%; /* Unten Links */
|
||||
background-position: 0% 100%;
|
||||
/* Unten Links */
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0% 0%; /* Zurück zum Start Links Mitte */
|
||||
background-position: 0% 0%;
|
||||
/* Zurück zum Start Links Mitte */
|
||||
}
|
||||
}
|
||||
|
||||
.q-footer .q-tab__label {
|
||||
font-size: 0.7rem; /* Passe diesen Wert nach Bedarf an */
|
||||
font-size: 0.7rem;
|
||||
/* Passe diesen Wert nach Bedarf an */
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -655,7 +684,8 @@ export default {
|
|||
/* Menu container for sliding panels */
|
||||
.menu-container {
|
||||
display: flex;
|
||||
width: calc(100% * 3); /* Width for 3 levels */
|
||||
width: calc(100% * 3);
|
||||
/* Width for 3 levels */
|
||||
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
}
|
||||
|
||||
|
|
|
|||
68
frontend/src/pages/LoginPage.vue
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<template>
|
||||
<q-page padding class="flex flex-center">
|
||||
<q-card style="width: 350px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Login</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-form @submit.prevent="onSubmit" class="q-gutter-md">
|
||||
<q-input
|
||||
v-model="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
filled
|
||||
:rules="[val => !!val || 'Email is required']"
|
||||
/>
|
||||
|
||||
<q-input
|
||||
v-model="password"
|
||||
type="password"
|
||||
label="Password"
|
||||
filled
|
||||
:rules="[val => !!val || 'Password is required']"
|
||||
/>
|
||||
|
||||
<div class="q-mt-md">
|
||||
<q-btn label="Login" type="submit" color="primary" class="full-width"/>
|
||||
</div>
|
||||
|
||||
<div class="text-center q-mt-sm">
|
||||
<router-link to="/password-reset" class="text-primary">Forgot your password?</router-link>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue'
|
||||
|
||||
export default {
|
||||
name: 'LoginPage',
|
||||
setup() {
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
|
||||
const onSubmit = () => {
|
||||
console.log('Login attempt with:', email.value, password.value)
|
||||
|
||||
// Save login status
|
||||
localStorage.setItem('isLoggedIn', 'true')
|
||||
window.dispatchEvent(new Event('storage'))
|
||||
|
||||
console.log('Redirecting to wave page...')
|
||||
|
||||
// Direct navigation
|
||||
window.location.href = '/#/wave'
|
||||
}
|
||||
|
||||
return {
|
||||
email,
|
||||
password,
|
||||
onSubmit
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
95
frontend/src/pages/PasswordResetPage.vue
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<template>
|
||||
<q-page padding class="flex flex-center">
|
||||
<q-card style="width: 350px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Password Reset</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<p class="text-body2 q-mb-md">
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</p>
|
||||
|
||||
<q-form @submit="onSubmit" class="q-gutter-md">
|
||||
<q-input
|
||||
v-model="email"
|
||||
type="email"
|
||||
label="Email"
|
||||
filled
|
||||
:rules="[val => !!val || 'Email is required']"
|
||||
/>
|
||||
|
||||
<div class="q-mt-md">
|
||||
<q-btn
|
||||
label="Send Reset Link"
|
||||
type="submit"
|
||||
color="primary"
|
||||
class="full-width"
|
||||
:loading="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-center q-mt-sm">
|
||||
<router-link to="/login" class="text-primary">Back to login</router-link>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
|
||||
<q-dialog v-model="successDialog">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Email Sent</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<p>If an account exists with that email, we've sent instructions to reset your password.</p>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Close" color="primary" v-close-popup @click="goToLogin" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
export default {
|
||||
name: 'PasswordResetPage',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const email = ref('')
|
||||
const loading = ref(false)
|
||||
const successDialog = ref(false)
|
||||
|
||||
const onSubmit = () => {
|
||||
loading.value = true
|
||||
|
||||
// Here you would call your API to request password reset
|
||||
console.log('Password reset requested for:', email.value)
|
||||
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
successDialog.value = true
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
const goToLogin = () => {
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
return {
|
||||
email,
|
||||
loading,
|
||||
onSubmit,
|
||||
successDialog,
|
||||
goToLogin
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
138
frontend/src/pages/SignUpPage.vue
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<template>
|
||||
<q-page padding class="flex flex-center">
|
||||
<q-card style="width: 400px">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Sign Up</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<q-form @submit="onSubmit" class="q-gutter-md">
|
||||
<q-input v-model="firstName" label="First Name" filled :rules="[val => !!val || 'First name is required']" />
|
||||
<q-input v-model="lastName" label="Last Name" filled :rules="[val => !!val || 'Last name is required']" />
|
||||
<!-- Add gender selection -->
|
||||
<q-select v-model="gender" :options="genderOptions" label="Gender" filled
|
||||
:rules="[val => !!val || 'Please select a gender']" />
|
||||
<q-input v-model="email" type="email" label="Email" filled :rules="[
|
||||
val => !!val || 'Email is required',
|
||||
val => /^[^@]+@[^@]+\.[^@]+$/.test(val) || 'Please enter a valid email'
|
||||
]" />
|
||||
<q-input v-model="password" type="password" label="Password" filled :rules="[
|
||||
val => !!val || 'Password is required',
|
||||
val => val.length >= 8 || 'Password must be at least 8 characters'
|
||||
]" />
|
||||
<q-input v-model="confirmPassword" type="password" label="Confirm Password" filled :rules="[
|
||||
val => !!val || 'Please confirm your password',
|
||||
val => val === password || 'Passwords do not match'
|
||||
]" />
|
||||
<q-checkbox v-model="termsAccepted" label="I agree to the Terms of Service">
|
||||
<template v-slot:default>
|
||||
I agree to the <a href="#" @click.prevent="showTerms = true" class="text-primary">Terms of Service</a>
|
||||
</template>
|
||||
</q-checkbox>
|
||||
<div class="q-mt-md">
|
||||
<q-btn label="Sign Up" type="submit" color="primary" class="full-width" :loading="loading"
|
||||
:disable="!termsAccepted" />
|
||||
</div>
|
||||
<div class="text-center q-mt-sm">
|
||||
Already have an account? <router-link to="/login" class="text-primary">Log in</router-link>
|
||||
</div>
|
||||
</q-form>
|
||||
</q-card-section>
|
||||
<q-dialog v-model="successDialog">
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-h6">Registration Successful</div>
|
||||
</q-card-section>
|
||||
<q-card-section>
|
||||
<p>Your account has been created successfully. You can now log in.</p>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Go to Login" color="primary" v-close-popup @click="goToLogin" />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
<q-dialog v-model="showTerms">
|
||||
<q-card style="width: 700px; max-width: 80vw">
|
||||
<q-card-section>
|
||||
<div class="text-h6">Terms of Service</div>
|
||||
</q-card-section>
|
||||
<q-card-section style="max-height: 70vh" class="scroll">
|
||||
<p>These Terms of Service ("Terms") govern your use of our website and services.</p>
|
||||
<p>By using our services, you agree to these Terms. Please read them carefully.</p>
|
||||
<h6 class="text-subtitle1 q-mt-md">1. Your Account</h6>
|
||||
<p>You are responsible for safeguarding your account and for any activities or actions under your account.
|
||||
</p>
|
||||
<h6 class="text-subtitle1 q-mt-md">2. Privacy</h6>
|
||||
<p>Our Privacy Policy explains how we treat your personal data and protect your privacy.</p>
|
||||
<!-- Add more terms as needed -->
|
||||
<h6 class="text-subtitle1 q-mt-md">3. Termination</h6>
|
||||
<p>We may terminate or suspend your account at any time for any reason.</p>
|
||||
</q-card-section>
|
||||
<q-card-actions align="right">
|
||||
<q-btn flat label="Close" color="primary" v-close-popup />
|
||||
</q-card-actions>
|
||||
</q-card>
|
||||
</q-dialog>
|
||||
</q-card>
|
||||
</q-page>
|
||||
</template>
|
||||
<script>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
export default {
|
||||
name: 'SignUpPage',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const firstName = ref('')
|
||||
const lastName = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const termsAccepted = ref(false)
|
||||
const loading = ref(false)
|
||||
const successDialog = ref(false)
|
||||
const showTerms = ref(false)
|
||||
// Add gender field
|
||||
const gender = ref('')
|
||||
const genderOptions = [
|
||||
'Male',
|
||||
'Female',
|
||||
'Non-binary',
|
||||
'Prefer not to say'
|
||||
]
|
||||
const onSubmit = () => {
|
||||
loading.value = true
|
||||
// Here you would call your API to register the user
|
||||
console.log('Sign up attempt with:', {
|
||||
firstName: firstName.value,
|
||||
lastName: lastName.value,
|
||||
gender: gender.value,
|
||||
email: email.value,
|
||||
password: password.value
|
||||
})
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
successDialog.value = true
|
||||
}, 1500)
|
||||
}
|
||||
const goToLogin = () => {
|
||||
router.push('/login')
|
||||
}
|
||||
return {
|
||||
firstName,
|
||||
lastName,
|
||||
gender,
|
||||
genderOptions,
|
||||
email,
|
||||
password,
|
||||
confirmPassword,
|
||||
termsAccepted,
|
||||
loading,
|
||||
onSubmit,
|
||||
successDialog,
|
||||
showTerms,
|
||||
goToLogin
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
313
frontend/src/pages/WavePage.vue
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
<template>
|
||||
<q-page>
|
||||
<div class="visualization-container">
|
||||
<div class="gradient-bg"></div>
|
||||
<div class="scroll-container" id="scroll-container"></div>
|
||||
<div class="median"></div>
|
||||
</div>
|
||||
</q-page>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { onMounted, onBeforeUnmount, defineComponent } from 'vue'
|
||||
import { ConnectedDotsVisualization } from "../utils/ConnectedDotsVisualization"
|
||||
|
||||
export default defineComponent({
|
||||
name: 'WavePage',
|
||||
setup() {
|
||||
let visualization = null;
|
||||
let isDown = false;
|
||||
let startX;
|
||||
let scrollLeft;
|
||||
|
||||
// Function to handle cleanup of event listeners
|
||||
const cleanupEventListeners = () => {
|
||||
const scrollContainer = document.querySelector('.scroll-container');
|
||||
if (scrollContainer) {
|
||||
scrollContainer.removeEventListener('mousedown', handleMouseDown);
|
||||
scrollContainer.removeEventListener('mouseleave', handleMouseLeave);
|
||||
scrollContainer.removeEventListener('mouseup', handleMouseUp);
|
||||
scrollContainer.removeEventListener('mousemove', handleMouseMove);
|
||||
scrollContainer.removeEventListener('touchstart', handleTouchStart);
|
||||
scrollContainer.removeEventListener('touchend', handleTouchEnd);
|
||||
scrollContainer.removeEventListener('touchmove', handleTouchMove);
|
||||
}
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
|
||||
// Event handlers
|
||||
const handleResize = () => {
|
||||
if (visualization) {
|
||||
visualization.resize();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
const scrollContainer = e.currentTarget;
|
||||
isDown = true;
|
||||
scrollContainer.classList.add('active');
|
||||
startX = e.pageX - scrollContainer.offsetLeft;
|
||||
scrollLeft = scrollContainer.scrollLeft;
|
||||
scrollContainer.classList.remove('smooth-scroll');
|
||||
};
|
||||
|
||||
const handleMouseLeave = (e) => {
|
||||
if (!isDown) return;
|
||||
isDown = false;
|
||||
e.currentTarget.classList.remove('active');
|
||||
e.currentTarget.classList.add('smooth-scroll');
|
||||
};
|
||||
|
||||
const handleMouseUp = (e) => {
|
||||
if (!isDown) return;
|
||||
isDown = false;
|
||||
e.currentTarget.classList.remove('active');
|
||||
e.currentTarget.classList.add('smooth-scroll');
|
||||
};
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
if (!isDown) return;
|
||||
e.preventDefault();
|
||||
const scrollContainer = e.currentTarget;
|
||||
const x = e.pageX - scrollContainer.offsetLeft;
|
||||
const walk = (x - startX) * 3;
|
||||
scrollContainer.scrollLeft = scrollLeft - walk;
|
||||
};
|
||||
|
||||
const handleTouchStart = (e) => {
|
||||
const scrollContainer = e.currentTarget;
|
||||
isDown = true;
|
||||
scrollContainer.classList.add('active');
|
||||
startX = e.touches[0].pageX - scrollContainer.offsetLeft;
|
||||
scrollLeft = scrollContainer.scrollLeft;
|
||||
scrollContainer.classList.remove('smooth-scroll');
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e) => {
|
||||
if (!isDown) return;
|
||||
isDown = false;
|
||||
e.currentTarget.classList.remove('active');
|
||||
e.currentTarget.classList.add('smooth-scroll');
|
||||
};
|
||||
|
||||
const handleTouchMove = (e) => {
|
||||
if (!isDown) return;
|
||||
e.preventDefault();
|
||||
const scrollContainer = e.currentTarget;
|
||||
const x = e.touches[0].pageX - scrollContainer.offsetLeft;
|
||||
const walk = (x - startX) * 3;
|
||||
scrollContainer.scrollLeft = scrollLeft - walk;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
console.log("Initializing Wave visualization...");
|
||||
|
||||
// Example sample dots data
|
||||
const sampleDots = [
|
||||
{
|
||||
id: 1,
|
||||
value: -1.8,
|
||||
x: -2,
|
||||
imageUrl: "/images/0_3.png",
|
||||
title: "Beginn des neuen Abenteuers",
|
||||
description: "01.10.2024",
|
||||
link: "/page1",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
value: 1.2,
|
||||
x: 0,
|
||||
imageUrl: "/images/0_2.png",
|
||||
title: "Omas Annis Geburtstag",
|
||||
description: "02.10.2024",
|
||||
link: "/page2",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
value: -0.6,
|
||||
x: 2,
|
||||
imageUrl: "/images/disco.png",
|
||||
title: "Konzertbesuch mit Freunden",
|
||||
description: "03.10.2024",
|
||||
link: "/page3",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
value: 3,
|
||||
x: 4,
|
||||
imageUrl: "/images/pferd.png",
|
||||
title: "Wanderreiten in den Bergen",
|
||||
description: "04.10.2024",
|
||||
link: "/page4",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
value: 1,
|
||||
x: 6,
|
||||
imageUrl: "/images/gpt.png",
|
||||
title: "Ruhiger Tag zu Hause",
|
||||
description: "05.10.2024",
|
||||
link: "/page5",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
value: -3,
|
||||
x: 8,
|
||||
imageUrl: "/images/oma.png",
|
||||
title: "Oma Erna verstorben",
|
||||
description: "06.10.2024",
|
||||
link: "/page6",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
value: 1.5,
|
||||
x: 10,
|
||||
imageUrl: "/images/see.png",
|
||||
title: "Erholungsausflug zum See",
|
||||
description: "07.10.2024",
|
||||
link: "/page7",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
value: 0,
|
||||
x: 12,
|
||||
imageUrl: "/images/feier.png",
|
||||
title: "Kleine Wochenendsfeier",
|
||||
description: "08.10.2024",
|
||||
link: "/page8",
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
value: 3,
|
||||
x: 14,
|
||||
imageUrl: "/images/hochzeit.png",
|
||||
title: "Hochzeit von Cousine Tatjana",
|
||||
description: "09.10.2024",
|
||||
link: "/page9",
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
value: 1,
|
||||
x: 16,
|
||||
imageUrl: "/images/work.png",
|
||||
title: "Erster Tag im neuen Job",
|
||||
description: "10.10.2024",
|
||||
link: "/page10",
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
value: -1.2,
|
||||
x: 18,
|
||||
imageUrl: "/images/klasse.png",
|
||||
title: "Klassentreffen nach vielen Jahren",
|
||||
description: "11.10.2024",
|
||||
link: "/page11",
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
value: -0.6,
|
||||
x: 20,
|
||||
imageUrl: "/images/familie.png",
|
||||
title: "Familienabendessen",
|
||||
description: "12.10.2024",
|
||||
link: "/page12",
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
value: 2.7,
|
||||
x: 22,
|
||||
imageUrl:
|
||||
"/images/kinobesuch.png",
|
||||
title: "Kinobesuch mit der ganzen Familie",
|
||||
description: "13.10.2024",
|
||||
link: "/page13",
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
value: 0,
|
||||
x: 24,
|
||||
imageUrl:
|
||||
"/images/entspannung.png",
|
||||
title: "Entspannung",
|
||||
description: "14.10.2024",
|
||||
link: "/page14",
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
value: -2.9,
|
||||
x: 26,
|
||||
imageUrl: "/images/sonntag.png",
|
||||
title: "Geruhsamer Sonntag",
|
||||
description: "15.10.2024",
|
||||
link: "/page15",
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
value: 1.5,
|
||||
x: 28,
|
||||
imageUrl:
|
||||
"/images/kindergeburtstag.png",
|
||||
title: "Kindergeburtstag",
|
||||
description: "16.10.2024",
|
||||
link: "/page16",
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
value: 0,
|
||||
x: 30,
|
||||
imageUrl:
|
||||
"/images/familie2.png",
|
||||
title: "Spaziergang mit der Familie",
|
||||
description: "17.10.2024",
|
||||
link: "/page17",
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
value: 2.1,
|
||||
x: 32,
|
||||
imageUrl:
|
||||
"/images/grosseltern.png",
|
||||
title: "Familienfeier bei den Großeltern",
|
||||
description: "18.10.2024",
|
||||
link: "/page18",
|
||||
},
|
||||
];
|
||||
|
||||
// Initialize the visualization with the sample dots
|
||||
visualization = new ConnectedDotsVisualization('scroll-container', sampleDots, {
|
||||
// Optional custom configuration
|
||||
dotRadius: 6,
|
||||
});
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
// Set up scroll interactions
|
||||
const scrollContainer = document.querySelector('.scroll-container');
|
||||
if (scrollContainer) {
|
||||
// Mouse events
|
||||
scrollContainer.addEventListener('mousedown', handleMouseDown);
|
||||
scrollContainer.addEventListener('mouseleave', handleMouseLeave);
|
||||
scrollContainer.addEventListener('mouseup', handleMouseUp);
|
||||
scrollContainer.addEventListener('mousemove', handleMouseMove);
|
||||
|
||||
// Touch events
|
||||
scrollContainer.addEventListener('touchstart', handleTouchStart);
|
||||
scrollContainer.addEventListener('touchend', handleTouchEnd);
|
||||
scrollContainer.addEventListener('touchmove', handleTouchMove);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error initializing visualization:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up event listeners when component is unmounted
|
||||
onBeforeUnmount(() => {
|
||||
cleanupEventListeners();
|
||||
});
|
||||
|
||||
return {}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
@ -3,16 +3,35 @@ const routes = [
|
|||
path: '/',
|
||||
component: () => import('layouts/MainLayout.vue'),
|
||||
children: [
|
||||
{ path: '', component: () => import('pages/IndexPage.vue') }
|
||||
]
|
||||
{ path: '', component: () => import('pages/IndexPage.vue') },
|
||||
{
|
||||
path: 'login',
|
||||
name: 'login',
|
||||
component: () => import('pages/LoginPage.vue'),
|
||||
},
|
||||
{
|
||||
path: 'sign-up',
|
||||
name: 'sign-up',
|
||||
component: () => import('pages/SignUpPage.vue'),
|
||||
},
|
||||
{
|
||||
path: 'password-reset',
|
||||
name: 'password-reset',
|
||||
component: () => import('pages/PasswordResetPage.vue'),
|
||||
},
|
||||
{
|
||||
path: 'wave',
|
||||
name: 'wave',
|
||||
component: () => import('pages/WavePage.vue')
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Always leave this as last one,
|
||||
// but you can also remove it
|
||||
// Always leave this as last one
|
||||
{
|
||||
path: '/:catchAll(.*)*',
|
||||
component: () => import('pages/ErrorNotFound.vue')
|
||||
}
|
||||
component: () => import('pages/ErrorNotFound.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
export default routes
|
||||
|
|
|
|||
519
frontend/src/utils/ConnectedDotsVisualization.ts
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
// Define interfaces
|
||||
export interface DotConfig {
|
||||
id: number;
|
||||
value: number;
|
||||
x: number;
|
||||
link?: string; // URL to navigate to when dot is clicked
|
||||
imageUrl?: string; // Image to display in tooltip
|
||||
title?: string; // Optional title for the tooltip
|
||||
description?: string; // Optional description for the tooltip
|
||||
}
|
||||
export interface Config {
|
||||
totalWidth: number;
|
||||
height: number;
|
||||
dotRadius: number;
|
||||
xUnitSize: number;
|
||||
tension: number;
|
||||
showGrid: boolean;
|
||||
tooltipWidth: number;
|
||||
tooltipHeight: number;
|
||||
}
|
||||
interface ControlPoints {
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
interface TooltipEdges {
|
||||
leftmost: number;
|
||||
rightmost: number;
|
||||
}
|
||||
export class ConnectedDotsVisualization {
|
||||
private config: Config;
|
||||
private dots: DotConfig[];
|
||||
private preloadedImages: Map<string, HTMLImageElement> = new Map();
|
||||
// DOM Elements
|
||||
private scrollContainer: HTMLElement;
|
||||
private svg: SVGElement;
|
||||
private gridGroup: SVGGElement;
|
||||
private curvePath: SVGPathElement;
|
||||
private dotsGroup: SVGGElement;
|
||||
private tooltipGroup: SVGGElement;
|
||||
// Active tooltip
|
||||
private activeTooltip: SVGElement | null = null;
|
||||
constructor(
|
||||
containerId: string,
|
||||
dots: DotConfig[],
|
||||
config?: Partial<Config>
|
||||
) {
|
||||
// Use the provided dots or empty array
|
||||
this.dots = dots || [];
|
||||
// Calculate the total width based on dots data
|
||||
const xUnitSize = config?.xUnitSize || 100;
|
||||
let calculatedWidth = 0;
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map((dot) => dot.x));
|
||||
const maxX = Math.max(...this.dots.map((dot) => dot.x));
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
calculatedWidth = (maxX - minX + 6) * xUnitSize;
|
||||
} else {
|
||||
calculatedWidth = 6 * xUnitSize; // Default width if no dots
|
||||
}
|
||||
// Default configuration
|
||||
this.config = {
|
||||
totalWidth: calculatedWidth,
|
||||
height: window.innerHeight,
|
||||
dotRadius: 6,
|
||||
xUnitSize: xUnitSize,
|
||||
tension: 0.5,
|
||||
showGrid: false,
|
||||
tooltipWidth: 128,
|
||||
tooltipHeight: 128,
|
||||
...config,
|
||||
};
|
||||
// Initialize DOM elements
|
||||
this.scrollContainer = document.getElementById(containerId) as HTMLElement;
|
||||
// Create SVG elements
|
||||
this.svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
this.gridGroup = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"g"
|
||||
);
|
||||
this.curvePath = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"path"
|
||||
);
|
||||
this.dotsGroup = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"g"
|
||||
);
|
||||
this.tooltipGroup = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"g"
|
||||
);
|
||||
// Initialize the visualization
|
||||
this.addStyles();
|
||||
this.initializeSVG();
|
||||
this.setupEventListeners();
|
||||
this.preloadImages();
|
||||
this.render();
|
||||
}
|
||||
private preloadImages(): void {
|
||||
// Extract all unique image URLs from dots
|
||||
const imageUrls: string[] = this.dots
|
||||
.filter((dot) => dot.imageUrl)
|
||||
// biome-ignore lint/style/noNonNullAssertion: <explanation>
|
||||
.map((dot) => dot.imageUrl!)
|
||||
.filter((url, index, self) => self.indexOf(url) === index); // Remove duplicates
|
||||
// Create a loading indicator (optional)
|
||||
const loadingCount = { current: 0, total: imageUrls.length };
|
||||
if (imageUrls.length > 0) {
|
||||
console.log(`Preloading ${imageUrls.length} images...`);
|
||||
}
|
||||
// Preload each image
|
||||
for (const url of imageUrls) {
|
||||
const img = new Image();
|
||||
// Optional loading events
|
||||
img.onload = () => {
|
||||
loadingCount.current++;
|
||||
if (loadingCount.current === loadingCount.total) {
|
||||
console.log("All images preloaded successfully");
|
||||
}
|
||||
};
|
||||
img.onerror = () => {
|
||||
loadingCount.current++;
|
||||
console.error(`Failed to preload image: ${url}`);
|
||||
};
|
||||
// Set src to start loading
|
||||
img.src = url;
|
||||
// Store in map for potential later use
|
||||
this.preloadedImages.set(url, img);
|
||||
}
|
||||
}
|
||||
private addStyles(): void {
|
||||
// Add necessary styles for tooltips and interactions
|
||||
const styleId = "connected-dots-styles";
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement("style");
|
||||
style.id = styleId;
|
||||
// style.textContent = `
|
||||
|
||||
// `;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
}
|
||||
private initializeSVG(): void {
|
||||
// Configure SVG
|
||||
this.svg.setAttribute("width", `${this.config.totalWidth}`);
|
||||
this.svg.setAttribute("height", `${this.config.height}`);
|
||||
this.svg.style.overflow = "visible";
|
||||
this.scrollContainer.appendChild(this.svg);
|
||||
// Configure grid group
|
||||
this.gridGroup.classList.add("grid");
|
||||
this.svg.appendChild(this.gridGroup);
|
||||
// Configure curve path
|
||||
this.curvePath.setAttribute("fill", "none");
|
||||
this.curvePath.setAttribute("stroke", "white");
|
||||
this.curvePath.setAttribute("stroke-width", "2");
|
||||
this.curvePath.setAttribute("stroke-linecap", "round");
|
||||
this.curvePath.classList.add("curve-path");
|
||||
this.svg.appendChild(this.curvePath);
|
||||
// Configure dots group
|
||||
this.svg.appendChild(this.dotsGroup);
|
||||
// Configure tooltip group (always on top)
|
||||
this.tooltipGroup.classList.add("tooltips");
|
||||
this.svg.appendChild(this.tooltipGroup);
|
||||
}
|
||||
private setupEventListeners(): void {
|
||||
// Event listeners removed as the controls were removed
|
||||
}
|
||||
private getDotX(x: number): number {
|
||||
return (x + 3) * this.config.xUnitSize;
|
||||
}
|
||||
private getDotY(value: number): number {
|
||||
const centerY = this.config.height / 1.95;
|
||||
// Calculate raw Y position
|
||||
// height of the amplitude
|
||||
const rawY = centerY - (value / 3) * ((this.config.height / 2) * 0.6);
|
||||
// Calculate minimum Y position to ensure tooltip fits
|
||||
const minY = this.config.tooltipHeight + 40; // tooltip height + some padding
|
||||
// Ensure Y is never less than minimum (never too high on screen)
|
||||
return Math.max(rawY, minY);
|
||||
}
|
||||
private calculateBezierControlPoints(
|
||||
dots: DotConfig[],
|
||||
index: number
|
||||
): ControlPoints {
|
||||
const tension = this.config.tension * 500; // Scale tension for Bezier curve Rundung Kurve
|
||||
// Get current point and its neighbors
|
||||
const curr = dots[index];
|
||||
const next = dots[index + 1];
|
||||
// Calculate control points for a smooth bezier curve
|
||||
const x1 = this.getDotX(curr.x) + tension;
|
||||
const y1 = this.getDotY(curr.value);
|
||||
const x2 = this.getDotX(next.x) - tension;
|
||||
const y2 = this.getDotY(next.value);
|
||||
return { x1, y1, x2, y2 };
|
||||
}
|
||||
private generateBezierPath(): string {
|
||||
if (this.dots.length < 2) return "";
|
||||
let path = `M ${this.getDotX(this.dots[0].x)} ${this.getDotY(
|
||||
this.dots[0].value
|
||||
)}`;
|
||||
for (let i = 0; i < this.dots.length - 1; i++) {
|
||||
const { x1, y1, x2, y2 } = this.calculateBezierControlPoints(
|
||||
this.dots,
|
||||
i
|
||||
);
|
||||
const nextX = this.getDotX(this.dots[i + 1].x);
|
||||
const nextY = this.getDotY(this.dots[i + 1].value);
|
||||
path += ` C ${x1} ${y1}, ${x2} ${y2}, ${nextX} ${nextY}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
private drawGrid(): void {
|
||||
// Clear previous grid
|
||||
while (this.gridGroup.firstChild) {
|
||||
this.gridGroup.removeChild(this.gridGroup.firstChild);
|
||||
}
|
||||
if (!this.config.showGrid) return;
|
||||
// Horizontal grid lines
|
||||
for (const value of [-3, -2, -1, 0, 1, 2, 3]) {
|
||||
const line = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"line"
|
||||
);
|
||||
line.setAttribute("x1", "0");
|
||||
line.setAttribute("y1", this.getDotY(value).toString());
|
||||
line.setAttribute("x2", this.config.totalWidth.toString());
|
||||
line.setAttribute("y2", this.getDotY(value).toString());
|
||||
line.setAttribute("stroke", "rgba(219, 39, 119, 0.4)");
|
||||
line.setAttribute("stroke-width", "1");
|
||||
this.gridGroup.appendChild(line);
|
||||
const text = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"text"
|
||||
);
|
||||
text.setAttribute("x", "10");
|
||||
text.setAttribute("y", (this.getDotY(value) + 4).toString());
|
||||
text.setAttribute("fill", "rgba(219, 39, 119, 0.8)");
|
||||
text.setAttribute("font-size", "12");
|
||||
text.textContent = value.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
// Vertical grid lines
|
||||
const numVertLines = Math.ceil(
|
||||
this.config.totalWidth / this.config.xUnitSize
|
||||
);
|
||||
for (let i = 0; i < numVertLines; i++) {
|
||||
const x = i * this.config.xUnitSize;
|
||||
const xValue = i - 3; // Starting from -3
|
||||
const line = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"line"
|
||||
);
|
||||
line.setAttribute("x1", x.toString());
|
||||
line.setAttribute("y1", "0");
|
||||
line.setAttribute("x2", x.toString());
|
||||
line.setAttribute("y2", this.config.height.toString());
|
||||
line.setAttribute("stroke", "rgba(219, 39, 119, 0.4)");
|
||||
line.setAttribute("stroke-width", "1");
|
||||
this.gridGroup.appendChild(line);
|
||||
if (xValue !== 0) {
|
||||
const text = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"text"
|
||||
);
|
||||
text.setAttribute("x", x.toString());
|
||||
text.setAttribute("y", (this.config.height / 2 + 20).toString());
|
||||
text.setAttribute("fill", "rgba(219, 39, 119, 0.8)");
|
||||
text.setAttribute("font-size", "12");
|
||||
text.setAttribute("text-anchor", "middle");
|
||||
text.textContent = xValue.toString();
|
||||
this.gridGroup.appendChild(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private createTooltip(dot: DotConfig, x: number, y: number): SVGElement {
|
||||
const tooltip = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
||||
tooltip.classList.add("dot-tooltip");
|
||||
tooltip.setAttribute("data-dot-id", dot.id.toString());
|
||||
|
||||
// Calculate tooltip dimensions and position
|
||||
const tooltipWidth = 128; // Base width for your tooltip
|
||||
const tooltipHeight = (4 / 3) * tooltipWidth;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
let tooltipY = y - tooltipHeight - 10; // Positioned above the dot
|
||||
tooltipY = Math.max(tooltipY, 10); // Ensure it doesn't go above the view
|
||||
|
||||
// Create background rectangle
|
||||
const bg = document.createElementNS("http://www.w3.org/2000/svg", "rect");
|
||||
bg.setAttribute("x", tooltipX.toString());
|
||||
bg.setAttribute("y", tooltipY.toString());
|
||||
bg.setAttribute("width", tooltipWidth.toString());
|
||||
bg.setAttribute("height", tooltipHeight.toString());
|
||||
bg.setAttribute("rx", "0"); // Rounded corners
|
||||
bg.classList.add("tooltip-background");
|
||||
tooltip.appendChild(bg);
|
||||
|
||||
// Create foreignObject for the content
|
||||
const contentContainer = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"foreignObject"
|
||||
);
|
||||
contentContainer.setAttribute("x", tooltipX.toString());
|
||||
contentContainer.setAttribute("y", tooltipY.toString());
|
||||
contentContainer.setAttribute("width", tooltipWidth.toString());
|
||||
contentContainer.setAttribute("height", tooltipHeight.toString());
|
||||
|
||||
// Create a div to contain the content
|
||||
const div = document.createElement("div");
|
||||
div.classList.add("tooltip-content");
|
||||
|
||||
// Add title if available
|
||||
if (dot.title) {
|
||||
const title = document.createElement("div");
|
||||
title.textContent = dot.title;
|
||||
title.classList.add("tooltip-title");
|
||||
div.appendChild(title);
|
||||
}
|
||||
|
||||
// Add description if available
|
||||
if (dot.description) {
|
||||
const desc = document.createElement("div");
|
||||
desc.textContent = dot.description;
|
||||
desc.classList.add("tooltip-description");
|
||||
div.appendChild(desc);
|
||||
}
|
||||
|
||||
// Add image if available
|
||||
// Create a container div
|
||||
const imageContainer = document.createElement("div");
|
||||
imageContainer.classList.add("image_container"); // Add image_container class
|
||||
|
||||
// Define a variable for handling case with or without link
|
||||
let imgWrapper: HTMLElement;
|
||||
|
||||
// if (dot.imageUrl) {
|
||||
if (dot.link) {
|
||||
const link = document.createElement("a");
|
||||
link.href = dot.link;
|
||||
link.target = "_self"; // Opens in the same window
|
||||
|
||||
const imgElement = document.createElement("img");
|
||||
imgElement.src = dot.imageUrl;
|
||||
imgElement.classList.add("tooltip-image");
|
||||
|
||||
// Append the image element to the link
|
||||
link.appendChild(imgElement);
|
||||
imgWrapper = link; // Use the link as the wrapper
|
||||
|
||||
// Add the event listener to the link
|
||||
link.addEventListener("click", () => {
|
||||
if (dot.link) {
|
||||
window.location.href = dot.link;
|
||||
} else {
|
||||
console.error("Dot has no link");
|
||||
throw new Error("Dot has no link");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const img = document.createElement("img");
|
||||
img.src = dot.imageUrl;
|
||||
img.classList.add("tooltip-image");
|
||||
imgWrapper = img; // Use the image directly as the wrapper
|
||||
}
|
||||
// } else {
|
||||
// console.error("Dot has no image URL");
|
||||
// throw new Error("Dot has no image URL");
|
||||
// }
|
||||
|
||||
// Append imageWrapper to the container
|
||||
imageContainer.appendChild(imgWrapper);
|
||||
|
||||
|
||||
// Append the image container to the main div
|
||||
div.appendChild(imageContainer);
|
||||
|
||||
const arrow = document.createElement("div");
|
||||
|
||||
arrow.classList.add("tooltip-arrow");
|
||||
|
||||
div.appendChild(arrow); // Append the arrow to the tooltip-content div
|
||||
|
||||
contentContainer.appendChild(div);
|
||||
tooltip.appendChild(contentContainer);
|
||||
|
||||
return tooltip;
|
||||
}
|
||||
|
||||
private showTooltip(dot: DotConfig, x: number, y: number): void {
|
||||
// Create tooltip
|
||||
const tooltip = this.createTooltip(dot, x, y);
|
||||
this.tooltipGroup.appendChild(tooltip);
|
||||
this.activeTooltip = tooltip;
|
||||
}
|
||||
private hideTooltip(): void {
|
||||
// This method is kept for compatibility but doesn't hide tooltips anymore
|
||||
}
|
||||
private drawCurve(): void {
|
||||
const pathData = this.generateBezierPath();
|
||||
this.curvePath.setAttribute("d", pathData);
|
||||
}
|
||||
private calculateTooltipEdges(): TooltipEdges {
|
||||
let leftmost = 0;
|
||||
let rightmost = 0;
|
||||
let firstTooltipFound = false;
|
||||
// If no dots with tooltips, return default values
|
||||
if (this.dots.length === 0) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
// Calculate the leftmost and rightmost edges of all tooltips
|
||||
for (const dot of this.dots) {
|
||||
// Skip dots without tooltip content
|
||||
if (!dot.imageUrl && !dot.title && !dot.description) {
|
||||
continue;
|
||||
}
|
||||
const x = this.getDotX(dot.x);
|
||||
const tooltipWidth = this.config.tooltipWidth;
|
||||
const tooltipX = x - tooltipWidth / 2;
|
||||
if (!firstTooltipFound) {
|
||||
leftmost = tooltipX;
|
||||
rightmost = tooltipX + tooltipWidth;
|
||||
firstTooltipFound = true;
|
||||
} else {
|
||||
// Update leftmost and rightmost values
|
||||
leftmost = Math.min(leftmost, tooltipX);
|
||||
rightmost = Math.max(rightmost, tooltipX + tooltipWidth);
|
||||
}
|
||||
}
|
||||
// If no dots with tooltips were found, use default values
|
||||
if (!firstTooltipFound) {
|
||||
return { leftmost: 0, rightmost: this.config.totalWidth };
|
||||
}
|
||||
return { leftmost, rightmost };
|
||||
}
|
||||
private drawDots(): void {
|
||||
// Clear previous dots
|
||||
while (this.dotsGroup.firstChild) {
|
||||
this.dotsGroup.removeChild(this.dotsGroup.firstChild);
|
||||
}
|
||||
// Clear previous tooltips
|
||||
while (this.tooltipGroup.firstChild) {
|
||||
this.tooltipGroup.removeChild(this.tooltipGroup.firstChild);
|
||||
}
|
||||
for (const dot of this.dots) {
|
||||
const x = this.getDotX(dot.x);
|
||||
const y = this.getDotY(dot.value);
|
||||
const circle = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"circle"
|
||||
);
|
||||
circle.setAttribute("cx", x.toString());
|
||||
circle.setAttribute("cy", y.toString());
|
||||
circle.setAttribute("r", this.config.dotRadius.toString());
|
||||
circle.setAttribute("fill", "white");
|
||||
circle.setAttribute("data-dot-id", dot.id.toString());
|
||||
circle.classList.add("dot");
|
||||
// Always show tooltip if it has content
|
||||
if (dot.imageUrl || dot.title || dot.description) {
|
||||
this.showTooltip(dot, x, y);
|
||||
}
|
||||
// Click event for navigation
|
||||
if (dot.link) {
|
||||
circle.addEventListener("click", () => {
|
||||
if (dot.link) {
|
||||
window.location.href = dot.link;
|
||||
} else {
|
||||
console.error("Dot has no link");
|
||||
throw new Error("Dot has no link");
|
||||
}
|
||||
});
|
||||
}
|
||||
this.dotsGroup.appendChild(circle);
|
||||
}
|
||||
}
|
||||
public render(): void {
|
||||
this.drawGrid();
|
||||
this.drawCurve();
|
||||
this.drawDots();
|
||||
// Calculate tooltip edges and set SVG width
|
||||
const { leftmost, rightmost } = this.calculateTooltipEdges();
|
||||
// Set the SVG width based on the rightmost tooltip edge
|
||||
if (rightmost > 0) {
|
||||
// Add some padding
|
||||
const padding = 40;
|
||||
this.config.totalWidth = rightmost + padding;
|
||||
this.svg.setAttribute("width", `${this.config.totalWidth}`);
|
||||
// Update grid width
|
||||
this.drawGrid();
|
||||
}
|
||||
}
|
||||
// Public API methods for external use
|
||||
public updateDots(newDots: DotConfig[]): void {
|
||||
this.dots = newDots;
|
||||
// Initial width calculation based on dot positions (for grid)
|
||||
if (this.dots.length > 0) {
|
||||
// Find the minimum and maximum x values
|
||||
const minX = Math.min(...this.dots.map((dot) => dot.x));
|
||||
const maxX = Math.max(...this.dots.map((dot) => dot.x));
|
||||
// Calculate width based on the range of x values
|
||||
// Add padding on both sides (3 units on each side)
|
||||
this.config.totalWidth = (maxX - minX + 6) * this.config.xUnitSize;
|
||||
}
|
||||
// Render will calculate the tooltip edges and update the SVG width
|
||||
this.render();
|
||||
}
|
||||
public updateConfig(newConfig: Partial<Config>): void {
|
||||
this.config = { ...this.config, ...newConfig };
|
||||
this.render();
|
||||
}
|
||||
public resize(): void {
|
||||
this.config.height = window.innerHeight;
|
||||
this.svg.setAttribute("height", `${this.config.height}`);
|
||||
this.render();
|
||||
}
|
||||
}
|
||||