Compare commits

..

No commits in common. "master" and "galery-to-article" have entirely different histories.

23 changed files with 399 additions and 466 deletions

13
TODO Normal file
View file

@ -0,0 +1,13 @@
Nastylovat:
- lazyload načítat o něco dříve (neli zrušit alespoň mimo galerii)
- mobilní verze
Frontend:
- přidat v galerii zvětsovač na obrázky
- notyfi
Naplánovat:
- kurzy + cena: vyskakovací okno na rezervaci s jménem lekce? jak to vyřešit s kalendářem? (stránka /rezervace)
Backend:
- přidat posílání mailu panu vrchnímu

View file

@ -20,9 +20,6 @@ env = environ.Env()
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/
@ -165,6 +162,9 @@ STATIC_URL = '/static/'
# os.path.join(BASE_DIR, 'static') # os.path.join(BASE_DIR, 'static')
# ] # ]
MEDIA_URL = '/media/'
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

View file

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 126 KiB

View file

@ -19,7 +19,7 @@ class ArticleImageSerializer(serializers.ModelSerializer):
@staticmethod @staticmethod
def get_title(obj): def get_title(obj):
return obj.article.title if obj.article else '' return obj.article.title
class ArticleListSerializer(serializers.ModelSerializer): class ArticleListSerializer(serializers.ModelSerializer):
@ -35,15 +35,10 @@ class ArticleListSerializer(serializers.ModelSerializer):
def get_date(obj): def get_date(obj):
return obj.date.strftime("%-d. %-m. %Y") return obj.date.strftime("%-d. %-m. %Y")
def get_image(self, obj): @staticmethod
main_image = next(iter(obj.images.all()), None) def get_image(obj):
main_image = obj.images.order_by("main").first()
if main_image: return main_image.image.url if main_image else None
return ArticleImageSerializer(main_image, context=self.context).data
return {
"image": "http://localhost:8000/media/images/image.jpg",
"title": "Výchozí obrázek",
}
class EventListSerializer(serializers.ModelSerializer): class EventListSerializer(serializers.ModelSerializer):

View file

@ -1,10 +1,9 @@
from django.utils import timezone from django.utils import timezone
from django.db.models import Q, Prefetch from django.db.models import Q
from post_office import mail from post_office import mail
from rest_framework.generics import ListAPIView, CreateAPIView from rest_framework.generics import ListAPIView, CreateAPIView
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response from rest_framework.response import Response
from tko.models import Article, Event, ArticleImage from tko.models import Article, Event, ArticleImage
@ -46,13 +45,10 @@ class AllArticleListView(ListAPIView):
def get_queryset(self): def get_queryset(self):
return Article.objects.filter( return Article.objects.filter(
Q(active_to__gte=timezone.now()) | Q(active_to__isnull=True) Q(active_to__gte=timezone.now()) | Q(active_to__isnull=True)
).order_by('-date').prefetch_related( ).order_by('-date')
Prefetch('images', queryset=ArticleImage.objects.order_by('-main'))
)
class GalleryView(ListAPIView): class GalleryView(ListAPIView):
permission=[AllowAny]
queryset = ArticleImage.objects.all().order_by("-article_id", "main") queryset = ArticleImage.objects.all().order_by("-article_id", "main")
serializer_class = ArticleImageSerializer serializer_class = ArticleImageSerializer

View file

@ -6,50 +6,49 @@
} }
h1 { h1 {
font-family: 'Futura', sans-serif;
font-size: 3rem; font-size: 3rem;
color: #333; color: #333;
text-align: center; text-align: center;
margin-bottom: 1rem; margin-bottom: 1rem;
margin-top: 2rem; margin-top: 2rem;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1); text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1);
cursor: default;
} }
h2 { h2 {
font-family: 'Futura', sans-serif;
font-size: 1.5rem; font-size: 1.5rem;
color: #CF3476; color: #CF3476;
text-align: center; text-align: center;
word-break: break-word;
margin-bottom: 2rem; margin-bottom: 2rem;
cursor: default;
} }
h3 { h3 {
font-family: 'Futura', sans-serif;
font-size: 1.5rem; font-size: 1.5rem;
color: #666; color: #666;
text-align: center; text-align: center;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
cursor: default;
} }
h4 { h4 {
font-family: 'Futura', sans-serif;
font-size: 1rem; font-size: 1rem;
color: #666; color: #666;
text-align: center; text-align: center;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
cursor: default;
} }
h5 { h5 {
font-family: 'Futura', sans-serif;
font-size: 1rem; font-size: 1rem;
color: #aaa; color: #aaa;
margin-top: 1rem; margin-top: 1rem;
cursor: default;
} }
@media (max-width: 600px) { @media (max-width: 600px) {
h1 { h1 {
font-size: 1.8rem; /* Smaller size for mobile */ font-size: 2rem; /* Smaller size for mobile */
margin-top: 1.5rem; margin-top: 1.5rem;
} }
@ -62,7 +61,6 @@ h5 {
min-width: 25rem; min-width: 25rem;
margin-left: calc(20% - 2.5rem); margin-left: calc(20% - 2.5rem);
margin-right: calc(20% - 2.5rem); margin-right: calc(20% - 2.5rem);
margin-bottom: 3rem;
} }
.app__logo { .app__logo {
@ -71,26 +69,30 @@ h5 {
} }
.app__title { .app__title {
font-family: 'Futura', sans-serif;
font-size: 2rem; font-size: 2rem;
color: #333; color: #333;
cursor: default;
} }
.app__tab { .app__tab {
font-family: 'Futura', sans-serif;
font-size: 2rem; font-size: 2rem;
color: #CF3476; color: #CF3476;
} }
.to_left { .to_left {
font-family: 'Futura', sans-serif;
font-size: 1rem; font-size: 1rem;
} }
.to_right { .to_right {
font-family: 'Futura', sans-serif;
float: right; float: right;
} }
.contact { .contact {
margin: 20px calc(20% - 40px) 50px; margin: 20px calc(20% - 40px) 50px;
min-width: 25rem;
border-radius: 15px; border-radius: 15px;
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.3); box-shadow: 0 5px 10px rgba(0, 0, 0, 0.3);
} }
@ -100,17 +102,16 @@ h5 {
font-size: 3rem; font-size: 3rem;
color: #333; color: #333;
margin-bottom: 1rem; margin-bottom: 1rem;
font-family: 'Futura', sans-serif;
font-weight: bold; font-weight: bold;
cursor: default;
} }
.contact__dialog__title { .contact__dialog__title {
font-family: 'Futura', sans-serif;
font-size: 2rem; font-size: 2rem;
color: #333; color: #333;
margin-bottom: 1rem; margin-bottom: 1rem;
font-weight: bold; font-weight: bold;
word-break: break-word !important;
cursor: default;
} }
.contact__button { .contact__button {
@ -120,15 +121,6 @@ h5 {
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.contact__title {
font-size: 1.5rem;
}
.contact {
margin: 10px;
padding: 1rem;
}
.v-dialog > .v-card { .v-dialog > .v-card {
border-radius: 0 !important; border-radius: 0 !important;
height: 100vh; height: 100vh;
@ -138,62 +130,65 @@ h5 {
.contact__dialog__title { .contact__dialog__title {
font-size: 1.5rem; font-size: 1.5rem;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
word-break: break-word;
} }
} }
.articles { .articles {
display: flex; margin-left: calc(20% - 2.5rem);
justify-content: center; margin-right: calc(20% - 2.5rem);
margin: 0 auto;
max-width: 1200px;
cursor: default;
} }
.article { .article {
padding: 1rem; padding: 10px;
min-width: 40%; min-width: 40%;
border-radius: 0; border-radius: 15px;
box-shadow: 0 0 0 rgba(0, 0, 0, 0); box-shadow: 0 5px 10px rgba(0, 0, 0, 0.3);
margin-bottom: 1rem; margin-bottom: 1rem;
cursor: default; }
.article__image {
border-radius: 0.5rem;
}
.carousel__image img {
border-radius: 0.5rem;
object-fit: contain !important;
padding: 1rem;
} }
.article__title { .article__title {
font-size: 1.4rem; font-family: 'Futura', sans-serif;
font-size: 1.5rem;
color: #CF3476; color: #CF3476;
text-align: left;
margin-top: 0.1rem;
font-weight: bold; font-weight: bold;
cursor: default;
} }
.article__date { .article__date {
font-family: 'Futura', sans-serif;
font-size: 0.8rem; font-size: 0.8rem;
text-align: left; text-align: left;
cursor: default;
} }
.article__text { .article__text {
font-family: 'Futura', sans-serif;
font-size: 1rem; font-size: 1rem;
color: #333; text-align: left;
line-height: 1.5; min-height: 6rem;
padding: 0.5rem; color: #666;
margin-bottom: 1rem;
cursor: default;
}
.article__image:hover {
cursor: pointer;
} }
.article__sign { .article__sign {
text-align: right; font-family: 'Futura', sans-serif;
font-size: 1rem; font-size: 1rem;
font-style: italic; float: right;
color: #555; padding-right: 1rem;
cursor: default; color: #333;
} }
.show_more { .show_more {
font-family: 'Futura', sans-serif;
text-align: center; text-align: center;
color: #CF3476; color: #CF3476;
background: transparent; background: transparent;
@ -241,7 +236,6 @@ h5 {
.pricing-title { .pricing-title {
min-height: 3rem; min-height: 3rem;
cursor: pointer;
} }
.pricing-desc { .pricing-desc {
@ -249,6 +243,7 @@ h5 {
} }
.pricing__price { .pricing__price {
font-family: 'Futura', sans-serif;
font-size: 3rem; font-size: 3rem;
font-weight: bold; font-weight: bold;
color: #CF3476; color: #CF3476;
@ -256,9 +251,9 @@ h5 {
} }
.pricing__subtitle { .pricing__subtitle {
font-family: 'Futura', sans-serif;
font-size: 1rem; font-size: 1rem;
color: #555; color: #555;
word-break: break-word;
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
@ -267,12 +262,9 @@ h5 {
min-width: 100%; min-width: 100%;
} }
.pricing-title { .pricing-title,
font-size: 1.4rem;
}
.pricing-desc { .pricing-desc {
min-height: unset; min-height: unset; /* Removes forced height to prevent breakage */
} }
} }
@ -294,7 +286,6 @@ h5 {
height: 100%; height: 100%;
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
cursor: default;
} }
.trainer-avatar { .trainer-avatar {
@ -307,6 +298,7 @@ h5 {
overflow: hidden; overflow: hidden;
} }
/* Mobile Styles */
@media (max-width: 600px) { @media (max-width: 600px) {
.trainers__parallax { .trainers__parallax {
max-height: 30rem; max-height: 30rem;
@ -331,23 +323,22 @@ h5 {
margin-top: 1.2rem; margin-top: 1.2rem;
margin-left: calc(20% - 2.5rem); margin-left: calc(20% - 2.5rem);
margin-right: calc(20% - 2.5rem); margin-right: calc(20% - 2.5rem);
cursor: default;
} }
.advantage__title { .advantage__title {
font-family: 'Futura', sans-serif;
padding: 0.5rem 0; padding: 0.5rem 0;
color: #CF3476; color: #CF3476;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: bold; font-weight: bold;
margin-top: 0.1rem; margin-top: 0.1rem;
cursor: default;
} }
.advantage__text { .advantage__text {
font-family: 'Futura', sans-serif;
padding-bottom: 1rem; padding-bottom: 1rem;
font-size: 1rem; font-size: 1rem;
color: #333; color: #333;
cursor: default;
} }
@media (max-width: 600px) { @media (max-width: 600px) {
@ -371,16 +362,15 @@ h5 {
.about { .about {
background: transparent; background: transparent;
box-shadow: none; box-shadow: none;
height: auto; height: 100%;
max-width: 80%; max-width: 80%;
margin: 0 auto; margin: 0 auto;
text-align: center; text-align: center;
padding: 2rem; padding: 2rem;
cursor: default;
} }
.about__parallax { .about__parallax {
max-height: 28rem; max-height: 30rem;
margin-top: 2rem; margin-top: 2rem;
} }
@ -391,80 +381,60 @@ h5 {
@media (max-width: 600px) { @media (max-width: 600px) {
.history__parallax { .history__parallax {
max-height: 58rem; max-height: 45rem;
} }
} }
.about__title { .about__title {
font-size: 2.5rem; font-family: 'Futura', sans-serif;
font-size: 3rem;
text-align: center;
width: 100%;
display: block;
color: #fff; color: #fff;
margin-top: 1rem; margin-top: 1rem;
font-weight: bold; font-weight: bold;
word-break: break-word;
white-space: normal;
display: block;
cursor: default;
} }
.about__subtitle { .about__subtitle {
font-family: 'Futura', sans-serif;
color: #FF3D8C; color: #FF3D8C;
font-size: 1.25rem; font-size: 1.3rem;
margin-bottom: 1rem; text-align: center;
word-break: break-word; width: 100%;
font-weight: bold;
display: block; display: block;
cursor: default; margin-bottom: 0.5rem;
font-weight: bold;
} }
.about__text { .about__text {
font-family: 'Futura', sans-serif;
color: #ddd; color: #ddd;
font-size: 1.125rem; font-size: 1rem;
text-align: center;
padding: 0 1rem; padding: 0 1rem;
line-height: 1.6;
cursor: default;
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.about { .about {
max-width: 95%; max-width: 100%;
padding: 1rem; padding: 1rem;
} }
.about__parallax {
min-height: 32rem;
}
.about__title { .about__title {
font-size: 1.5rem; font-size: 1.5rem;
} }
.about__subtitle { .about__subtitle {
font-size: 1rem; font-size: 1rem;
word-break: break-word;
} }
.about__text { .about__text {
font-size: 0.95rem; font-size: 1rem;
padding: 0 10px;
} }
} }
.masonry-gallery {
columns: 3;
column-gap: 16px;
}
@media (max-width: 600px) {
.masonry-gallery {
columns: 1;
}
}
.masonry-item {
break-inside: avoid;
margin-bottom: 16px;
}
.footer { .footer {
background-color: black; background-color: black;
cursor: default;
} }

View file

@ -4,12 +4,12 @@
src="public/dark-dance.jpg" src="public/dark-dance.jpg"
scale="0.8" scale="0.8"
> >
<v-container> <v-container id="about">
<v-card class="about" id="about"> <v-card class="about">
<v-card-title class="about__title">Vítejte v tanečním klubu!</v-card-title> <v-card-title class="about__title">Vítejte v tanečním klubu!</v-card-title>
<v-card-subtitle class="about__subtitle">Objevte kouzlo tance s námi!</v-card-subtitle> <v-card-subtitle class="about__subtitle">Vítejte na webu Tanečního klubu Ostrava!</v-card-subtitle>
<v-card-text class="about__text"> <v-card-text class="about__text">
jste začátečník nebo zkušený tanečník, v našem klubu najdete místo, kde se můžete rozvíjet, bavit a sdílet svou vášeň pro pohyb. Nabízíme kurzy pro všechny věkové kategorie, od společenských tanců po moderní styly. Objevte kouzlo tance s námi! jste začátečník nebo zkušený tanečník, v našem klubu najdete místo, kde se můžete rozvíjet, bavit a sdílet svou vášeň pro pohyb. Nabízíme kurzy pro všechny věkové kategorie, od společenských tanců po moderní styly.
<br><br> <br><br>
Přidejte se k nám a nechte tanec proměnit váš život! 💃🕺 Přidejte se k nám a nechte tanec proměnit váš život! 💃🕺
<br><br> <br><br>

View file

@ -1,6 +1,6 @@
<template> <template>
<h1>Přínosy tance</h1> <h1>Přínosy tance: Více než jen pohyb</h1>
<h2>Více než jen pohyb</h2> <h2>Tancem k lepšímu životu</h2>
<v-card <v-card
v-for="advantage in advantages" v-for="advantage in advantages"
:key="advantage.id" :key="advantage.id"

View file

@ -1,7 +1,7 @@
<template> <template>
<v-card class="contact pa-4"> <v-card class="contact">
<v-card-title class="contact__title">Kontaktujte nás!</v-card-title> <v-card-title class="contact__title">Kontaktujte nás!</v-card-title>
<lazy-dialog-contact-form /> <dialog-contact-form />
</v-card> </v-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View file

@ -14,10 +14,10 @@
<v-card class="pricing-box" @click="openDialog = true; chosenCourse = course"> <v-card class="pricing-box" @click="openDialog = true; chosenCourse = course">
<div class="pricing-content"> <div class="pricing-content">
<h3 class="pricing-title">{{ course.name }}</h3> <h3 class="pricing-title">{{ course.name }}</h3>
<v-card-subtitle v-if="course.time"> <v-card-subtitle>
<div class="pricing__subtitle">{{ course.time }}</div> <div class="pricing__subtitle">{{ course.time }}</div>
</v-card-subtitle> </v-card-subtitle>
<v-card-title v-if="course.price"> <v-card-title>
<div class="pricing__price">{{ course.price }}</div> <div class="pricing__price">{{ course.price }}</div>
</v-card-title> </v-card-title>
<v-card-text v-if="course.desc"> <v-card-text v-if="course.desc">
@ -38,28 +38,29 @@
transition="dialog-bottom-transition" transition="dialog-bottom-transition"
> >
<v-card class="contact pa-4"> <v-card class="contact pa-4">
<h1 class="contact__dialog__title d-flex align-center"> <v-card-title class="contact__dialog__title d-flex align-center">
<span class="flex-grow-1 text-center" style="margin-left: 40px;">{{ chosenCourse.name }}</span> <span class="flex-grow-1 text-center" style="margin-left: 40px;">{{ chosenCourse.name }}</span>
<v-btn icon="mdi-close" variant="text" @click="openDialog = false"></v-btn> <v-btn icon="mdi-close" variant="text" @click="openDialog = false"></v-btn>
</h1> </v-card-title>
<v-card-text v-if="chosenCourse.desc" class="text-center">
<div class="pricing__subtitle text-body-1">{{ chosenCourse.desc }}</div>
</v-card-text>
<v-divider class="my-4"></v-divider> <v-card-text v-if="chosenCourse.desc" class="text-center">
<div class="pricing__subtitle text-body-1">{{ chosenCourse.desc }}</div>
</v-card-text>
<v-divider class="my-4"></v-divider>
<v-card-title class="contact__dialog__title text-center"> <v-card-title class="contact__dialog__title text-center">
Kontaktujte nás! Kontaktujte nás!
</v-card-title> </v-card-title>
<dialog-contact-form class="mt-2" /> <dialog-contact-form class="mt-2" />
</v-card> </v-card>
</v-dialog> </v-dialog>
</template> </template>
<!-- <v-btn to="/kurzy" class="show_more">--> <!-- <v-btn to="/kurzy" class="show_more">-->
<!-- <v-icon icon="mdi-chevron-down"/>Více informací<v-icon icon="mdi-chevron-down"/>--> <!-- <v-icon icon="mdi-chevron-down"/>Více informací<v-icon icon="mdi-chevron-down"/>-->
<!-- </v-btn>--> <!-- </v-btn>-->
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import './assets/css/main.css' import './assets/css/main.css'

View file

@ -1,74 +1,70 @@
<template v-if="articles"> <template v-if="articles">
<h1 id="article">Aktuality</h1> <h1 id="article">Aktuality</h1>
<h2>Přečtěte si aktuality z našeho klubu</h2> <h2>Přečtěte si aktuality z našeho klubu</h2>
<v-row class="articles"> <v-row class="articles">
<v-col <v-col
v-for="article in articles" v-for="article in articles"
:key="article.id" :key="article.id"
cols="12" cols="12"
md="6" md="6"
> >
<v-card class="article"> <v-card class="article">
<v-row class="article__title">{{ article.title }}</v-row>
<v-row> <v-row>
<v-col> <v-col>
<v-img <v-img
:src="article.image.image" :lazy-src="article.image"
:lazy-src="article.image.image" :src="article.image"
class="article__image rounded-lg" aspect-ratio="1"
@click="openCarousel(article.image, article.images)" cover
class="article__image"
@click="showCarousel = true"
> >
<template v-slot:placeholder> <template v-slot:placeholder>
<v-row justify="center" align="center" class="fill-height"> <v-row justify="center" align="center" class="fill-height">
<v-progress-circular color="grey-lighten-5" indeterminate /> <v-progress-circular color="grey-lighten-5" indeterminate></v-progress-circular>
</v-row> </v-row>
</template> </template>
<v-dialog v-model="showCarousel">
<dialog-carousel
:image="article.image"
:images="article.images"
:title="article.title"
@isActive="showCarousel = false"
/>
</v-dialog>
</v-img> </v-img>
</v-col> </v-col>
<v-col cols="12" md="8"> <v-col
cols="12"
md="8"
>
<v-row class="article__title">{{ article.title }}</v-row>
<v-row class="article__date">{{ article.date}}</v-row>
<v-row class="article__text">{{ article.content }}</v-row> <v-row class="article__text">{{ article.content }}</v-row>
<v-row class="article__sign">{{ article.author }}</v-row>
</v-col> </v-col>
</v-row> </v-row>
<v-row class="article__sign">Dne {{ article.date }}, {{ article.author }}</v-row>
</v-card> </v-card>
</v-col> </v-col>
</v-row> </v-row>
<v-btn to="/aktuality" class="show_more">
<v-dialog v-model="showCarousel" max-width="90vw" v-if="selectedImages.length > 0"> <v-icon icon="mdi-chevron-down"/>Dalsí aktuality<v-icon icon="mdi-chevron-down"/>
<dialog-carousel
v-if="selectedImage"
:image="selectedImage"
:images="selectedImages"
@isActive="showCarousel = false"
/>
</v-dialog>
<v-btn to="/aktuality" class="show_more" v-if="!props.forAll">
<v-icon icon="mdi-chevron-down" />Další aktuality<v-icon icon="mdi-chevron-down" />
</v-btn> </v-btn>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import './assets/css/main.css' import './assets/css/main.css'
import { useAPI } from "~/composables/useAPI"; import { useAPI } from "~/composables/useAPI";
const showCarousel = ref(false); const showCarousel = ref(false);
const selectedImage = ref<ArticleImage | null>(null);
const selectedImages = ref<ArticleImage[]>([]);
const props = defineProps<{
forAll: boolean
}>();
interface ArticleImage { interface ArticleImage {
image: string; image: string;
title: string;
} }
interface Article { interface Article {
id: number; id: number;
title: string; title: string;
image: ArticleImage; image: string;
images: ArticleImage[]; images: ArticleImage[];
date: string; date: string;
content: string; content: string;
@ -77,18 +73,13 @@ interface Article {
const articles = ref<Article[]>([]); const articles = ref<Article[]>([]);
function openCarousel(image: ArticleImage, images: ArticleImage[]) { const { error, data } = await useAPI<Article[]>('load-articles/', {method: "GET"});
selectedImage.value = image;
selectedImages.value = images; if ( data.value ){
showCarousel.value = true; articles.value = data.value;
} }
else if (error.value) {
const endpoint = props.forAll ? 'load-all-articles/' : 'load-articles/';
const { error, data } = await useAPI<Article[]>(endpoint, { method: "GET" });
if (data.value) {
articles.value = data.value;
} else if (error.value) {
console.error("Error loading articles:", error.value); console.error("Error loading articles:", error.value);
} }
</script> </script>

View file

@ -1,11 +1,11 @@
<template> <template>
<v-parallax class="trainers__parallax" src="public/img/black-pink.jpg"> <v-parallax class="trainers__parallax" src="public/img/black-pink.jpg">
<v-container> <v-container id="trainers">
<v-card class="about" id="trainers"> <v-card class="trainers pa-4">
<v-card-title class="about__title">Naši trenéři a lektoři</v-card-title> <v-card-title class="about__title ">Naši trenéři a lektoři</v-card-title>
<v-card-subtitle class="about__subtitle">Seznamte se s námi!</v-card-subtitle> <v-card-subtitle class="about__subtitle ">Seznamte se s námi!</v-card-subtitle>
<v-carousel style="height: auto" cycle interval="4000" hide-delimiters :show-arrows="trainerGroups.length > 1 ? 'hover' : false"> <v-carousel cycle interval="4000" hide-delimiters show-arrows="hover">
<v-carousel-item v-for="(group, index) in trainerGroups" :key="index"> <v-carousel-item v-for="(group, index) in trainerGroups" :key="index">
<v-row justify="center"> <v-row justify="center">
<v-col <v-col
@ -25,8 +25,8 @@
> >
<v-img :src="lector.img" cover></v-img> <v-img :src="lector.img" cover></v-img>
</v-avatar> </v-avatar>
<div class="about__subtitle">{{ lector.name }}</div> <v-card-subtitle class="about__subtitle">{{ lector.name }}</v-card-subtitle>
<div class="about__text">{{ lector.desc }}</div> <v-card-text class="about__text">{{ lector.desc }}</v-card-text>
</v-col> </v-col>
</v-row> </v-row>
</v-carousel-item> </v-carousel-item>
@ -64,11 +64,6 @@ const lectors = [
img: "/trainers/img.png", img: "/trainers/img.png",
desc: "Lektorka - tance pro děti", desc: "Lektorka - tance pro děti",
}, },
{
name: "Ondřej Gilar",
img: "/trainers/img.png",
desc: "Trenér - latinskoamerické tance a Pro-AM",
},
] ]
const trainerGroups = computed(() => { const trainerGroups = computed(() => {

View file

@ -1,46 +1,35 @@
<template> <template>
<v-card class="article__image"> <v-card class="article__image">
<v-card-title> <v-card-title>{{ title }}
{{ images[model] && images[model].title }}
<v-icon class="to_right" @click="$emit('isActive', false)">mdi-close</v-icon> <v-icon class="to_right" @click="$emit('isActive', false)">mdi-close</v-icon>
</v-card-title> </v-card-title>
<v-carousel <v-carousel
v-model="model"
:hide-delimiters="true" :hide-delimiters="true"
height="90vh" height="90vh"
> >
<v-carousel-item <v-carousel-item
v-for="(item, i) in props.images" v-for="(item, i) in images"
:key="i" :key="i"
:src="item.image" :src="item.image"
cover cover
class="carousel__image" class="carousel__image"
/> ></v-carousel-item>
</v-carousel> </v-carousel>
</v-card> </v-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue';
import './assets/css/main.css'; import './assets/css/main.css';
interface ArticleImage { interface ArticleImage {
image: string; image: string;
title: string;
} }
const props = defineProps<{ defineProps<{
image: ArticleImage; image: string;
images: ArticleImage[]; images: ArticleImage[];
title: string;
}>(); }>();
defineEmits(["isActive"]); defineEmits(["isActive"])
const model = ref(0);
watch(() => props.image, (newImage) => {
const index = props.images.findIndex((img) => img.image === newImage.image);
if (index !== -1) model.value = index;
}, { immediate: true });
</script> </script>

View file

@ -1,12 +1,5 @@
<template> <template>
<v-form v-model="valid"> <v-form v-model="valid">
<v-snackbar
v-model="showSnackbar"
:color="snackbarColor"
:timeout="5000"
>
{{ snackbarMessage }}
</v-snackbar>
<v-container> <v-container>
<v-row> <v-row>
<v-col <v-col
@ -14,11 +7,7 @@
md="4" md="4"
> >
<v-text-field <v-text-field
dense
hide-details="auto"
v-model="fullName" v-model="fullName"
:error-messages="get(errorMessages, 'name')"
@change="hideError('name')"
label="Jméno" label="Jméno"
variant="underlined" variant="underlined"
></v-text-field> ></v-text-field>
@ -29,13 +18,7 @@
md="4" md="4"
> >
<v-text-field <v-text-field
dense
hide-details="auto"
type="email"
autocomplete="email"
v-model="email" v-model="email"
:error-messages="get(errorMessages, 'email')"
@change="hideError('email')"
label="Emailová adresa" label="Emailová adresa"
variant="underlined" variant="underlined"
></v-text-field> ></v-text-field>
@ -46,12 +29,7 @@
md="4" md="4"
> >
<v-text-field <v-text-field
dense
hide-details="auto"
type="tel"
v-model="phone" v-model="phone"
:error-messages="get(errorMessages, 'phone_number')"
@change="hideError('phone_number')"
label="Telefonní číslo" label="Telefonní číslo"
variant="underlined" variant="underlined"
></v-text-field> ></v-text-field>
@ -61,8 +39,6 @@
<v-col> <v-col>
<v-textarea <v-textarea
v-model="textField" v-model="textField"
:error-messages="get(errorMessages, 'content')"
@change="hideError('content')"
label="Váš dotaz" label="Váš dotaz"
variant="underlined" variant="underlined"
/> />
@ -71,8 +47,6 @@
<v-row> <v-row>
<v-col> <v-col>
<v-btn <v-btn
auto-grow
rows="3"
class="contact__button" class="contact__button"
type="submit" type="submit"
@click.prevent="sendContact" @click.prevent="sendContact"
@ -89,34 +63,14 @@ import './assets/css/main.css'
import {useAPI} from "~/composables/useAPI"; import {useAPI} from "~/composables/useAPI";
const valid = ref<boolean>(false); const valid = ref<boolean>(false);
const errorMessages = ref<Record<string, string[]>>({});
const fullName = ref<string>(""); const fullName = ref<string>("");
const email = ref<string>(""); const email = ref<string>("");
const phone = ref<string>(""); const phone = ref<string>("");
const textField = ref<string>(""); const textField = ref<string>("");
const showSnackbar = ref(false);
const snackbarMessage = ref("");
const snackbarColor = ref("red-accent-4")
function get(obj: any, path: string, defaultValue = undefined) {
return path.split('.').reduce((acc, part) => acc && acc[part], obj) ?? defaultValue;
}
function resetContractData() {
fullName.value = "";
email.value = "";
phone.value = "";
textField.value = "";
}
function hideError (key: string) {
delete errorMessages.value[key];
}
async function sendContact() { async function sendContact() {
const { error } = await useAPI('create-contact/', { const { data, error } = await useAPI('create-contact/', {
method: "POST", method: "POST",
body: { body: {
name: fullName.value, name: fullName.value,
@ -125,18 +79,6 @@ async function sendContact() {
content: textField.value, content: textField.value,
} }
}); });
if (error.value) {
snackbarMessage.value = "Něco se pokazilo. Zkuste to znovu.";
snackbarColor.value = "red-accent-4";
errorMessages.value = error.value.data;
} else {
snackbarMessage.value = "Děkujeme! Vaše zpráva byla odeslána.";
snackbarColor.value = "success";
resetContractData();
}
showSnackbar.value = true;
} }
</script> </script>

View file

@ -1,142 +1,103 @@
<template> <template>
<ClientOnly> <ClientOnly>
<v-layout style="margin-bottom: 3rem"> <v-layout>
<v-app-bar style="position: fixed"> <v-app-bar style="position: fixed">
<v-app-bar-nav-icon
@click="drawer = !drawer"
class="d-flex d-sm-none"
/>
<div> <div>
<a href="/"><v-img class="app__logo" src="/logo.png" /></a> <a href="/"><v-img class="app__logo" src="/logo.png" /></a>
</div> </div>
<v-app-bar-title class="app__title" v-if="$vuetify.display.smAndUp"> <v-app-bar-title class="app__title">Taneční klub Ostrava</v-app-bar-title>
Taneční klub Ostrava
</v-app-bar-title>
<v-tabs <v-tabs v-model="currentTab" class="d-none d-sm-flex app__tab">
v-if="$vuetify.display.smAndUp" <div
v-model="currentTabName"
class="app__tab"
>
<v-tab
v-for="(tab, index) in tabs" v-for="(tab, index) in tabs"
:key="index" :key="index"
:value="tab.name"
@click="handleNavigation(tab)"
> >
{{ tab.name }} <v-tab
</v-tab> v-if="tab.ref"
:text="tab.name"
:value="tab.name"
@click="useGoTo(tab.ref)"
>
{{ tab.name }}
</v-tab>
<v-tab
v-if="tab.href"
:text="tab.name"
:value="tab.name"
:href="tab.href"
>
{{ tab.name }}
</v-tab>
</div>
</v-tabs> </v-tabs>
<v-menu transition="slide-y-transition">
<template v-slot:activator="{ props }">
<v-app-bar-nav-icon
class="d-flex d-sm-none app__tab ml-auto"
style="padding-right: 1rem"
v-bind="props"
/>
</template>
<v-list> <!-- <v-col-->
<v-list-item <!-- class="text-right"-->
v-for="(item, i) in homeTabs" <!-- @click="toggleTheme"-->
:key="i" <!-- >-->
:value="i" <!-- <big-icon icon="mdi-lightbulb-cfl"/>-->
@click="handleNavigation(item)" <!-- </v-col>-->
>
<v-list-item-title>{{ item.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-app-bar> </v-app-bar>
<v-fab
class="ms-4 mb-4"
></v-fab>
<v-navigation-drawer
v-model="drawer"
absolute
fixed
left
>
<v-list nav dense>
<v-list-item v-for="(tab, index) in tabs" :key="index">
<v-list-item-title @click="useGoTo(tab.ref)">{{ tab.name }}</v-list-item-title>
</v-list-item>
</v-list>
</v-navigation-drawer>
</v-layout> </v-layout>
</ClientOnly> </ClientOnly>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, watch, nextTick } from 'vue'; import { useGoTo } from "~/composables/useGoTo";
import { useRoute, useRouter } from 'vue-router'; import {useRoute} from "#vue-router";
const route = useRoute(); const route = useRoute();
const router = useRouter();
const currentTab = ref({ name: 'O nás', ref: "#about", href: "o-nas" });
const drawer = ref(null)
const homeTabs = [ const homeTabs = [
{ name: "O nás", href: "#about" }, { name: "O nás", ref: "#about", href: "" },
{ name: "Trenéři", href: "#trainers" }, { name: "Trenéři", ref: "#trainers", href: "" },
{ name: "Kurzy", href: "#courses" }, { name: "Kurzy", ref: "#courses", href: "" },
{ name: "Galerie", href: "/galerie" }, { name: "Galerie", ref: "", href: "/galerie" },
{ name: "Aktuality", href: "#article" }, { name: "Aktuality", ref: "#article", href: "" },
{ name: "Kontakty", href: "/kontakty" }, { name: "Kontakty", ref: "", href: "/kontakty" },
]; ];
const otherTabs = [ const otherTabs = [
{ name: "O nás", href: "/#about" }, { name: "O nás", ref: "", href: "/" },
{ name: "Trenéři", href: "/#trainers" }, { name: "Trenéři", ref: "", href: "/" },
{ name: "Kurzy", href: "/#courses" }, { name: "Kurzy", ref: "", href: "/" },
{ name: "Galerie", href: "/galerie" }, { name: "Galerie", ref: "", href: "/galerie" },
{ name: "Aktuality", href: "/aktuality" }, { name: "Aktuality", ref: "", href: "/" },
{ name: "Kontakty", href: "/kontakty" }, { name: "Kontakty", ref: "", href: "/kontakty" },
]; ];
const tabs = computed(() => (route.path === "/" ? homeTabs : otherTabs)); const tabs = computed(() => (route.path === "/" ? homeTabs : otherTabs));
// import { useTheme } from 'vuetify'
//
// const theme = useTheme()
//
// function toggleTheme () {
// theme.global.name.value = theme.global.current.value.dark ? 'light' : 'dark'
// }
type Tab = {
name: string;
href: string;
};
const currentTab = ref<Tab>({ name: "O nás", href: "#about" });
const currentTabName = computed<string>({
get: () => currentTab.value.name,
set: (newName: string) => {
const tab = tabs.value.find(t => t.name === newName);
if (tab) handleNavigation(tab);
}
});
const handleNavigation = async (tab: { name: string, href: string }) => {
currentTab.value = tab;
if (tab.href.startsWith("#")) {
await scrollToHash(tab.href);
} else if (tab.href.startsWith("/#")) {
await router.push(tab.href);
} else {
await router.push(tab.href);
}
};
async function scrollToHash(hash: string) {
await nextTick();
const el = document.querySelector(hash);
if (el) {
const yOffset = -80;
const y = el.getBoundingClientRect().top + window.pageYOffset + yOffset;
window.scrollTo({ top: y, behavior: 'smooth' });
}
}
onMounted(() => {
if (route.path === "/" && route.hash) {
const match = homeTabs.find(tab => tab.href === route.hash);
if (match) {
currentTab.value = match;
}
scrollToHash(route.hash);
}
});
watch(() => route.hash, (newHash) => {
if (route.path === "/" && newHash) {
const match = homeTabs.find(tab => tab.href === newHash);
if (match) currentTab.value = match;
scrollToHash(newHash);
}
});
watch(() => route.path, (newPath) => {
if (newPath !== "/") {
const match = otherTabs.find(tab => tab.href === newPath || tab.href === `${newPath}${route.hash}`);
if (match) currentTab.value = match;
}
}, { immediate: true });
</script> </script>

View file

@ -7,35 +7,30 @@ export async function useGoTo(
): Promise<void> { ): Promise<void> {
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const yOffset = props?.offset ?? -150; const yOffset = props?.offset ?? -80;
const hash = selector.startsWith("#") ? selector : ""; const hash = selector.startsWith("#") ? selector : "";
const basePath = route.path.split("#")[0]; const path = selector.startsWith("/") ? selector : route.path + hash;
const targetPath = selector.startsWith("/") ? selector : basePath + hash;
const [pathOnly, hashOnly] = targetPath.split("#");
if (route.path !== pathOnly) { // If navigating to another page
// Navigate to target page if (route.path !== path.split("#")[0]) {
await router.push(pathOnly + (hashOnly ? `#${hashOnly}` : "")); await router.push(path);
await nextTick(); await nextTick(); // Ensure DOM updates
if (hashOnly) { // Wait for the element to exist before scrolling
try { try {
const element = await waitForElement(`#${hashOnly}`); const element = await waitForElement(hash);
scrollToElement(element, yOffset); scrollToElement(element, yOffset);
} catch (err) { } catch (error) {
console.warn(err); console.warn(error);
}
} }
} else { } else {
// Same page — scroll directly // If already on the correct page, scroll immediately
if (hashOnly || selector.startsWith("#")) { try {
try { const element = await waitForElement(hash);
const element = await waitForElement(`#${hashOnly ?? selector}`); scrollToElement(element, yOffset);
scrollToElement(element, yOffset); } catch (error) {
} catch (err) { console.warn(error);
console.warn(err);
}
} }
} }
} }

View file

@ -1,3 +1,70 @@
<template> <template v-if="articles">
<lazy-news :for-all="true"/> <h1 id="article">Aktuality</h1>
<h2>Přečtěte si aktuality z našeho klubu</h2>
<v-row>
<v-col
v-for="article in articles"
:key="article.id"
cols="12"
md="6"
>
<v-card class="article">
<v-row>
<v-col>
<v-img
:lazy-src="article.image"
:src="article.image"
aspect-ratio="1"
cover
class="article__image"
>
<template v-slot:placeholder>
<v-row>
<v-progress-circular
color="grey-lighten-5"
indeterminate
></v-progress-circular>
</v-row>
</template>
</v-img>
</v-col>
<v-col
cols="12"
md="8"
>
<v-row class="article__title">{{ article.title }}</v-row>
<v-row class="article__date">{{ article.date}}</v-row>
<v-row class="article__text">{{ article.content }}</v-row>
<v-row class="article__sign">{{ article.author }}</v-row>
</v-col>
</v-row>
</v-card>
</v-col>
</v-row>
</template> </template>
<script setup lang="ts">
import './assets/css/main.css'
import {useAPI} from "~/composables/useAPI";
interface Article {
id: number;
title: string;
image: string;
date: string;
content: string;
author: string;
}
const articles = ref<Article[]>([]);
const { error, data } = await useAPI('load-all-articles/', {method: "GET"});
if ( data.value ){
articles.value = data.value as Article[];
}
else if (error.value) {
console.error("Error loading articles:", error.value);
}
</script>

View file

@ -1,47 +1,44 @@
<template> <template>
<h1>Galerie</h1> <h1>Galerie</h1>
<div class="masonry-gallery"> <v-row>
<div <v-col
class="masonry-item"
v-for="(photo, index) in gallery" v-for="(photo, index) in gallery"
:key="index" :key="index"
@click="openCarousel(photo)" cols="4"
style="padding: 0;"
> >
<v-img <v-img
:src="photo.image"
:lazy-src="photo.image" :lazy-src="photo.image"
aspect-ratio="" :src="photo.image"
class="article__image rounded-lg" aspect-ratio="1"
cover cover
@click="showCarousel = true"
> >
<template #placeholder> <template v-slot:placeholder>
<v-row justify="center" align="center" style="height: 100px;"> <v-row>
<v-progress-circular indeterminate color="grey-lighten-1" /> <v-progress-circular
color="grey-lighten-5"
indeterminate
></v-progress-circular>
</v-row> </v-row>
</template> </template>
<v-dialog v-model="showCarousel">
<dialog-carousel
:image="photo.image"
:images="gallery"
:title="photo.title"
@isActive="showCarousel = false"
/>
</v-dialog>
</v-img> </v-img>
</div> </v-col>
<v-dialog v-model="showCarousel"> </v-row>
<dialog-carousel
v-if="selectedImage"
:image="selectedImage"
:images="gallery"
@isActive="showCarousel = false"
/>
</v-dialog>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import './assets/css/main.css' import './assets/css/main.css'
import { useAPI } from "~/composables/useAPI"; import { useAPI } from "~/composables/useAPI";
const showCarousel = ref(false); const showCarousel = ref(false);
const selectedImage = ref<ArticleImage | null>(null);
function openCarousel(photo: ArticleImage) {
selectedImage.value = photo;
showCarousel.value = true;
}
interface ArticleImage { interface ArticleImage {
image: string; image: string;
@ -50,20 +47,12 @@ interface ArticleImage {
const gallery = ref<ArticleImage[]>([]); const gallery = ref<ArticleImage[]>([]);
loadImages(); const { error, data } = await useAPI<ArticleImage[]>('load-gallery/', {method: "GET"});
async function loadImages(){ if ( data.value ){
const { error, data } = await useAPI<ArticleImage[]>('load-gallery/', {method: "GET"});
if ( data.value ){
gallery.value = data.value as ArticleImage[]; gallery.value = data.value as ArticleImage[];
}
else if (error.value) {
console.error("Error loading gallery:", error.value);
}
} }
else if (error.value) {
console.error("Error loading gallery:", error.value);
}
</script> </script>

View file

@ -11,12 +11,12 @@
</h2> </h2>
</div> </div>
</v-parallax> </v-parallax>
<lazy-news :for-all="false"/> <news/>
<lazy-about/> <about/>
<lazy-courses/> <courses/>
<lazy-trainers/> <trainers/>
<lazy-advantages/> <advantages/>
<lazy-history/> <history/>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">

View file

@ -1,4 +1,37 @@
<template> <template>
<lazy-courses /> <h1 id="courses">Kurzy</h1>
<lazy-calendar/> <h2>Vyberte, co Vám nejlépe vyhovuje</h2>
<v-row>
<v-col
cols="12"
md="4"
v-for="(course, index) in courses"
:key="index"
>
<v-card class="pricing-box">
<h3>{{ course.name }}</h3>
<v-card-subtitle><div class="pricing__subtitle">{{ course.time }}</div></v-card-subtitle>
<v-card-title><div class="pricing__price">{{ course.price }}</div></v-card-title>
<v-card-text v-if="course.desc"><div class="pricing__subtitle">{{ course.desc }}</div></v-card-text>
<v-btn class="show_more"><v-icon icon="mdi-chevron-down"/>Kontaktujte nás<v-icon icon="mdi-chevron-down"/></v-btn>
</v-card>
</v-col>
</v-row>
<calendar/>
</template> </template>
<script setup lang="ts">
import './assets/css/main.css'
// TODO: vyskakovací okno s kontaktním formulářem po rozkliku :)
const courses = [
{name: "Sportovní taneční klub", time: "", price: "", desc: "Připojte se k našemu tanečnímu klubu a rozvíjejte své taneční dovednosti v přátelském prostředí. Nabízíme různé styly tance pro všechny úrovně."},
{name: "Svatební tance", time: "", price: "", desc: "Udělejte svůj první tanec nezapomenutelným. Pomůžeme Vám vytvořit choreografii na míru, která bude odrážet Váš jedinečný styl."},
{name: "Příprava na plesovou sezónu", time: "", price: "", desc: "Chcete zazářit na plese? Připravíme Vás na plesovou sezónu a naučíme Vás elegantní taneční kroky a etiketu."},
{name: "Individuální taneční kurzy", time: "", price: "", desc: "Učte se tančit vlastním tempem s individuálním přístupem. Naši zkušení lektoři se zaměří na Vaše potřeby a pomohou Vám dosáhnout Vašich tanečních cílů."},
{name: "Individuální lekce", time: "", price: "", desc: "Zlepšete své taneční dovednosti s intenzivními individuálními lekcemi. Zaměřte se na konkrétní taneční techniky nebo styly, které Vás zajímají."},
{name: "Pronájem sálu", time: "", price: "", desc: "Hledáte ideální prostor pro tanec nebo jinou aktivitu? Náš taneční sál je k dispozici k pronájmu pro Vaše akce."},
]
</script>

View file

@ -62,10 +62,6 @@ export default defineNuxtPlugin((app) => {
cs: { cs: {
calendar: { calendar: {
today: "dnes", today: "dnes",
},
carousel: {
next: "další",
prev: "předchozí",
} }
} }
} }