This commit is contained in:
2025-08-01 13:38:52 +03:00
parent 4c7f79bb7f
commit 493f3a11e2
39 changed files with 851 additions and 376 deletions

Binary file not shown.

View File

@@ -14,15 +14,12 @@
<meta name="robots" content="noindex, nofollow"/> <meta name="robots" content="noindex, nofollow"/>
<meta name="msapplication-tap-highlight" content="no"> <meta name="msapplication-tap-highlight" content="no">
<script src="https://telegram.org/js/telegram-web-app.js"></script> <script src="https://telegram.org/js/telegram-web-app.js"></script>
<script src="http://localhost:8098"></script>
<!-- <!--
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>"> <meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>">
--> -->
<link rel="icon" type="image/png" sizes="128x128" href="icons/favicon-128x128.png"> <link rel="icon" href="icons/favicon.svg" type="image/svg+xml">
<link rel="icon" type="image/png" sizes="96x96" href="icons/favicon-96x96.png"> <link rel="icon" type="image/ico" href="icons/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon-32x32.png"> <link rel="apple-touch-icon" href="icons/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon-16x16.png">
<link rel="icon" type="image/ico" href="favicon.ico">
</head> </head>
<body> <body>
<!-- quasar:entry-point --> <!-- quasar:entry-point -->

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 859 B

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

BIN
public/icons/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

64
public/icons/favicon.svg Normal file
View File

@@ -0,0 +1,64 @@
<svg
viewBox="0 0 8.4666662 8.4666662"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
>
<defs id="defs1" />
<g id="layer1">
<rect
style="fill: #27A7E7 ;stroke-width:0.233149"
id="rect5"
width="6.9885192"
height="0.35581663"
x="3.114475"
y="0.86827624"
transform="matrix(0.77578367,0.63099897,-0.77578367,0.63099897,0,0)"
/>
<rect
style="fill: #27A7E7 ;stroke-width:0.24961"
id="rect5-7"
width="7.4819207"
height="0.3809379"
x="-3.9267058"
y="5.7988153"
transform="matrix(-0.70756824,0.70664502,0.70756824,0.70664502,0,0)"
/>
<circle
style="fill: #27A7E7 ;stroke-width:0.134869"
id="path5-8"
cx="1.5875"
cy="6.8791666"
r="1.0583333"
/>
<circle
style="fill: #27A7E7 ;stroke-width:0.168586"
id="path5-8-5"
cx="7.1437502"
cy="7.1437502"
r="1.3229166"
/>
<circle
style="fill: #27A7E7 ;stroke-width:0.118011"
id="path5-8-5-1"
cx="1.4552083"
cy="2.5135417"
r="0.92604166"
/>
<circle
style="fill: #27A7E7 ;stroke-width:0.101152"
id="path5-8-5-1-7"
cx="7.1437502"
cy="1.3229166"
r="0.79374999"
/>
<circle
style="fill: #F36D3A; stroke-width:0.23602"
id="path5"
cx="3.96875"
cy="4.4979167"
r="1.8520833"
/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -233,4 +233,4 @@ export default defineConfig((ctx) => {
extraScripts: [] extraScripts: []
} }
} }
}); })

View File

@@ -3,14 +3,13 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { inject, onMounted, ref } from 'vue' import { inject, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useAuthStore } from 'stores/auth' import { useAuthStore } from 'stores/auth'
import { useSettingsStore } from 'stores/settings' import { useSettingsStore } from 'stores/settings'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import type { WebApp } from '@twa-dev/types' import type { WebApp } from '@twa-dev/types'
const router = useRouter() const router = useRouter()
const tg = inject('tg') as WebApp const tg = inject('tg') as WebApp
@@ -30,39 +29,19 @@
} }
} }
function parseIdString (input: string): { id: number; taskId?: number; meetingId?: number } | null {
const pattern = /^p(?<id>\d+)(?:t(?<taskId>\d+))?(?:m(?<meetingId>\d+))?$/
const match = input.match(pattern)
if (!match?.groups?.id) return null
const id = parseInt(match.groups.id, 10)
const taskId = match.groups.taskId ? parseInt(match.groups.taskId, 10) : undefined
const meetingId = match.groups.meetingId ? parseInt(match.groups.meetingId, 10) : undefined
return {
id,
...(taskId !== undefined && { taskId }),
...(meetingId !== undefined && { meetingId })
}
}
const authStore = useAuthStore()
const settingsStore = useSettingsStore()
const startRouteInfo = ref<{ id: number; taskId?: number; meetingId?: number } | null>(null)
if (tg.initDataUnsafe.start_param) {
startRouteInfo.value = parseIdString(tg.initDataUnsafe.start_param)
}
onMounted(async () => { onMounted(async () => {
try { try {
if (startRouteInfo.value) authStore.setStartRouteInfo(startRouteInfo.value) const authStore = useAuthStore()
if (!authStore.isInit) await authStore.init(tg) const settingsStore = useSettingsStore()
if (!settingsStore.isInit) await settingsStore.init()
} catch { if (tg) {
// await router.push({ name: 'server_error'}) if (!authStore.isInit) await authStore.init(tg)
if (!settingsStore.isInit) await settingsStore.init()
}
} catch (error) {
console.error('App initialization failed:', error)
alert(error)
await router.push({ name: '404' })
} }
}) })

124
src/components/BaseLogo.vue Normal file
View File

@@ -0,0 +1,124 @@
<template>
<div
class="flex row items-center no-wrap logo-component"
>
<svg
class="iconcolor"
viewBox="0 0 8.4666662 8.4666662"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
>
<defs id="defs1" />
<g id="layer1">
<rect
class="fill-brand"
style="stroke-width:0.233149"
id="rect5"
width="6.9885192"
height="0.35581663"
x="3.114475"
y="0.86827624"
transform="matrix(0.77578367,0.63099897,-0.77578367,0.63099897,0,0)"
/>
<rect
class="fill-brand"
style="stroke-width:0.24961"
id="rect5-7"
width="7.4819207"
height="0.3809379"
x="-3.9267058"
y="5.7988153"
transform="matrix(-0.70756824,0.70664502,0.70756824,0.70664502,0,0)"
/>
<circle
class="fill-brand"
style="stroke-width:0.134869"
id="path5-8"
cx="1.5875"
cy="6.8791666"
r="1.0583333"
/>
<circle
class="fill-brand"
style="stroke-width:0.168586"
id="path5-8-5"
cx="7.1437502"
cy="7.1437502"
r="1.3229166"
/>
<circle
class="fill-brand"
style="stroke-width:0.118011"
id="path5-8-5-1"
cx="1.4552083"
cy="2.5135417"
r="0.92604166"
/>
<circle
class="fill-brand"
style="stroke-width:0.101152"
id="path5-8-5-1-7"
cx="7.1437502"
cy="1.3229166"
r="0.79374999"
/>
<circle
style="stroke-width:0.23602"
id="path5"
cx="3.96875"
cy="4.4979167"
r="1.8520833"
/>
</g>
</svg>
<span
class="text-brand"
style="margin-right: 0.075em;"
>
tg
</span>
<span class="text-brand2 text-bold">
Crew
</span>
</div>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.logo-component {
svg {
width: 1em;
height: 1em;
margin-right: 0.125em;
}
span {
line-height: 1;
}
}
.fill-brand {
fill: $brand;
}
@keyframes blink {
100%,
0% {
fill: $brand2;
}
60% {
fill: $brand2;
opacity: 0.8;
}
}
#path5 {
animation: blink 3s infinite;
}
</style>

View File

@@ -6,7 +6,7 @@
<template #footer> <template #footer>
<q-btn <q-btn
rounded color="primary" rounded color="primary"
class="w100 q-mt-md q-mb-xs" class="w100 q-mt-md q-mb-xs fix-disabled-btn"
:disable="!(isFormValid && (isDirty(initialMeeting, modelValue) || newFiles.length !== 0))" :disable="!(isFormValid && (isDirty(initialMeeting, modelValue) || newFiles.length !== 0))"
@click = "emit('update', newFiles)" @click = "emit('update', newFiles)"
> >

View File

@@ -0,0 +1,87 @@
<template>
<div class="flex row q-mb-sm q-mt-md q-mx-sm justify-between items-center no-wrap">
<q-btn
v-if="showCalendarBtn"
flat round
:color="calendarActive ? 'primary' : 'grey'"
@click="emit('toggle-calendar')"
>
<div>
<q-icon name="mdi-calendar-month-outline" size="sm"/>
<q-badge
color="red"
rounded
floating
transparent
style="position: relative; top: -6px; margin-left: -12px"
:style="{ opacity: calendarBadge ? 0.8 : 0 }"
/>
</div>
</q-btn>
<q-input
v-model="search"
clearable
clear-icon="close"
borderless
filled
:placeholder="$t(placeholder)"
dense
class="col-grow q-pt-xs q-mx-sm"
>
<template #prepend>
<q-icon name="mdi-magnify" color="grey"/>
</template>
</q-input>
<q-btn
v-if="showFilterBtn"
@click="emit('open-filters')"
flat round
:color="filterActive ? 'primary' : 'grey'"
>
<div>
<q-icon name="mdi-filter-outline" size="sm"/>
<q-badge
color="red"
rounded
floating
transparent
style="position: relative; top: -6px; margin-left: -12px"
:style="{ opacity: filterBadge ? 0.8 : 0 }"
/>
</div>
</q-btn>
</div>
</template>
<script setup lang="ts">
const search = defineModel<string>({
required: true,
default: ''
})
defineProps({
placeholder: {
type: String,
default: 'Search...'
},
showCalendarBtn: {
type: Boolean,
default: true
},
showFilterBtn: {
type: Boolean,
default: true
},
calendarActive: Boolean,
filterActive: Boolean,
calendarBadge: Boolean,
filterBadge: Boolean
})
const emit = defineEmits([
'toggle-calendar',
'open-filters'
])
</script>

View File

@@ -0,0 +1,125 @@
<template>
<q-item
@click="showDialog=true"
clickable
v-ripple
>
<q-item-section avatar>
<q-avatar
rounded
text-color="white"
:icon
:color="iconColor"
size="lg"
/>
</q-item-section>
<q-item-section>
<q-item-label>
{{ $t(title) }}
</q-item-label>
<q-item-label caption>
<slot name="value"/>
</q-item-label>
</q-item-section>
<q-item-section side>
<q-icon name="mdi-chevron-right" color="grey"/>
</q-item-section>
</q-item>
<q-dialog
v-model="showDialog"
maximized
transition-show="slide-up"
transition-hide="slide-down"
position="bottom"
>
<q-card
class="fix-card-width flex column no-scroll no-wrap q-px-none"
style="
border-top-left-radius: var(--top-raduis) !important;
border-top-right-radius: var(--top-raduis) !important;
"
>
<div
ref="cardHeaderRef"
class="flex items-center no-wrap justify-between w100 q-my-none q-pa-md"
>
<div>
<div class="flex column q-mx-xs">
<span class="text-h6 ellipsis">{{ $t(title) }}</span>
<span v-if="caption" class="text-grey text-caption">{{ $t(caption) }}</span>
</div>
</div>
<div class="flex items-center justify-between no-wrap">
<q-btn
icon="mdi-close"
@click="showDialog=false"
flat round
/>
</div>
</div>
<div
ref="cardBodyRef"
class="q-px-none q-ma-none"
>
<pn-shadow-scroll
:hideShadows="false"
:height="bodyHeight"
>
<div ref="cardBodyInnerRef" class="q-px-md q-ma-none">
<q-resize-observer @resize="updateDimensions" />
<slot/>
</div>
</pn-shadow-scroll>
</div>
<div
ref="cardFooterRef"
class="q-pa-md"
>
<slot name="footer"/>
</div>
</q-card>
</q-dialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useSlots } from 'vue'
defineProps<{
title: string
caption?: string
icon: string
iconColor: string
}>()
const showDialog=ref<boolean>(false)
const slots = useSlots()
const cardHeaderRef = ref<HTMLElement | null>(null)
const cardFooterRef = ref<HTMLElement | null>(null)
const cardBodyRef = ref<HTMLElement | null>(null)
const cardBodyInnerRef = ref<HTMLElement | null>(null)
const headerHeight = ref(0)
const footerHeight = ref(0)
const bodyInnerHeight = ref(0)
const bodyHeight = ref(0)
const updateDimensions = () => {
headerHeight.value = cardHeaderRef.value?.offsetHeight || 0
footerHeight.value = cardFooterRef.value?.offsetHeight || 0
bodyInnerHeight.value = cardBodyInnerRef.value?.offsetHeight || 0
bodyHeight.value = window.innerHeight - headerHeight.value - footerHeight.value - 48
}
watch(() => slots.body?.(), updateDimensions, { flush: 'post' })
</script>
<style scoped>
.fix-card-width {
width: var(--body-width) !important;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<q-list class="q-gutter-y-sm">
<q-btn
v-for="(option, index) in options"
:key="index"
flat
no-caps dense
class="w100"
align="left"
@click="selectItem(option)"
:class="isSelected(option.value) ? 'text-primary' : ''"
>
<q-icon
:name="isSelected(option.value) ? 'mdi-check' : ''"
color="primary"
class="q-pr-sm"
/>
<span
:class="!isSelected(option.value) ? 'text-weight-regular' : ''"
>
{{ $te(option.label) ? $t(option.label) : option.label }}
</span>
</q-btn>
</q-list>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface ListOption {
label: string
value: string | number
}
defineProps<{
options: ListOption[]
}>()
const model = defineModel<string | number | null>({
required: true
})
const isSelected = computed(() => (value: string | number) => {
return model.value === value
})
const selectItem = (option: ListOption) => {
model.value = option.value
}
</script>
<style scoped>
</style>

View File

@@ -6,7 +6,7 @@
<template #footer> <template #footer>
<q-btn <q-btn
rounded color="primary" rounded color="primary"
class="w100 q-mt-md q-mb-xs" class="w100 q-mt-md q-mb-xs fix-disabled-btn"
@click = "emit('update', newFiles)" @click = "emit('update', newFiles)"
:disable="!(isFormValid && (isDirty(initialTask, modelValue) || newFiles.length !== 0))" :disable="!(isFormValid && (isDirty(initialTask, modelValue) || newFiles.length !== 0))"
> >

View File

@@ -5,7 +5,7 @@
class="text-caption flex items-center w100" class="text-caption flex items-center w100"
:class="'text-' + taskStatus.color" :class="'text-' + taskStatus.color"
> >
<q-icon :name="taskStatus.icon"/> <q-icon :name="taskStatus.icon" class="q-pr-xs"/>
<span> <span>
{{ $t(taskStatus.text) }} {{ $t(taskStatus.text) }}
</span> </span>

View File

@@ -1,10 +1,18 @@
// app global css in SCSS form // app global css in SCSS form
.text-brand { .text-brand {
color: $green-14 !important; color: $brand !important;
} }
.bg-brand { .bg-brand {
background: $green-14 !important; background: $brand !important;
}
.text-brand2 {
color: $brand2 !important;
}
.bg-brand2 {
background: $brand2 !important;
} }
$base-width: 100; $base-width: 100;
@@ -13,10 +21,14 @@ $base-width: 100;
$base-width: $base-width - 10; $base-width: $base-width - 10;
} }
$base-height: 100; body, html, #q-app {
@while $base-height > 0 { font-family: $typography-font-family;
.h#{$base-height} { height: #{$base-height}+'%'; } -webkit-font-smoothing: antialiased;
$base-height: $base-height - 10; -moz-osx-font-smoothing: grayscale;
}
* {
font-family: inherit;
} }
:root { :root {
@@ -66,6 +78,20 @@ body {
margin: auto; margin: auto;
} }
@font-face {
font-family: 'myFont';
src: url(./fonts/Inter-Regular.woff2);
}
.fix-disabled-btn.q-btn[disabled]:not(.q-btn--flat) {
background-color: $grey-5 !important;
}
.fix-disabled-btn.q-btn.q-btn--flat[disabled] {
color: $grey-9 !important;
opacity: 1;
}
.pn-icon { .pn-icon {
font-family: 'pn-icon'; font-family: 'pn-icon';
font-style: normal; font-style: normal;
@@ -73,7 +99,7 @@ body {
@font-face { @font-face {
font-family: 'pn-icon'; font-family: 'pn-icon';
src: url("./fonts/pn.woff") format("woff") src: url(./fonts/pn.woff) format("woff")
} }
.icon-file-default:before { .icon-file-default:before {

Binary file not shown.

View File

@@ -1,18 +1,4 @@
// Quasar SCSS (& Sass) Variables $primary : #27A7E7;
// --------------------------------------------------
// To customize the look and feel of this app, you can override
// the Sass/SCSS variables found in Quasar's source Sass/SCSS files.
// Check documentation for full list of Quasar variables
// Your own variables (that are declared here) and Quasar's own
// ones will be available out of the box in your .vue/.scss/.sass files
// It's highly recommended to change the default colors
// to match your app's branding.
// Tip: Use the "Theme Builder" on Quasar's documentation website.
$primary : #1976D2;
$secondary : #26A69A; $secondary : #26A69A;
$accent : #9C27B0; $accent : #9C27B0;
@@ -26,4 +12,9 @@ $warning : #F2C037;
$lightgrey : #DCDCDC; $lightgrey : #DCDCDC;
$body-font-size: var(--dynamic-font-size) $brand: #27A7E7;
$brand2: #F36D3A;
$body-font-size: var(--dynamic-font-size);
$typography-font-family: 'myFont', Roboto !default;

View File

@@ -92,7 +92,25 @@ function parseIntString (s: string | string[] | undefined) :number | null {
return regex.test(s) ? Number(s) : null return regex.test(s) ? Number(s) : null
} }
function parseStartParams (input: string): { id: number; taskId?: number; meetingId?: number } | null {
const pattern = /^p(?<id>\d+)(?:t(?<taskId>\d+))?(?:m(?<meetingId>\d+))?$/
const match = input.match(pattern)
if (!match?.groups?.id) return null
const id = parseInt(match.groups.id, 10)
const taskId = match.groups.taskId ? parseInt(match.groups.taskId, 10) : undefined
const meetingId = match.groups.meetingId ? parseInt(match.groups.meetingId, 10) : undefined
return {
id,
...(taskId !== undefined && { taskId }),
...(meetingId !== undefined && { meetingId })
}
}
export { export {
isDirty, isDirty,
parseIntString parseIntString,
parseStartParams
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,149 @@
<template>
<div class="flex column w100 q-pa-none q-ma-none no-wrap" style="height: 100vh">
<div class="flex justify-center items-center w100 q-pb-md">
{{'👋 '+ $t('accept_terms__welcome_title')}}
<base-logo class="q-pa-xs"/>
!
</div>
<div class="col-grow">
<q-resize-observer @resize="onResize"/>
<pn-shadow-scroll :height="sectionHeight" :hideShadows="false">
<div class="q-px-md text-caption">
<div>
{{$t('accept_terms__welcome')}}
</div>
<div class="text-bold q-pt-md">
{{$t('accept_terms__section_privacy_data_title')}}
</div>
<div>
{{$t('accept_terms__section_privacy_data')}}
</div>
<ul>
<li> {{$t('accept_terms__section_privacy_data_option1')}}</li>
<li> {{$t('accept_terms__section_privacy_data_option2')}}</li>
<li> {{$t('accept_terms__section_privacy_data_option3')}}</li>
<li> {{$t('accept_terms__section_privacy_data_option4')}}</li>
</ul>
<div class="text-bold q-pt-md">
{{$t('accept_terms__section_choise_title')}}
</div>
<div>
{{$t('accept_terms__section_choise_agree')}}
</div>
<ul>
<li> {{$t('accept_terms__section_choise_agree_option1')}}</li>
<li> {{$t('accept_terms__section_choise_agree_option2')}}</li>
</ul>
<div>
{{$t('accept_terms__section_choise_decline')}}
</div>
<ul>
<li> {{$t('accept_terms__section_choise_decline_option1')}}</li>
<li> {{$t('accept_terms__section_choise_decline_option2')}}</li>
</ul>
<div>
{{$t('accept_terms__section_revoke')}}
</div>
</div>
</pn-shadow-scroll>
</div>
<div class="flex column w100 q-pt-md">
<div class="text-caption q-pb-md">
<div class="flex column q-gutter-y-md">
<div class="flex items-center no-wrap">
<q-checkbox v-model="agreement" val="1" dense class="q-px-sm"/>
<span>
{{$t('accept_terms__section_checkbox_agreement') + ' '}}
<a href="">{{ $t('accept_terms__section_checkbox_agreement_doc') }}</a>
</span>
</div>
<div class="flex items-center no-wrap">
<q-checkbox v-model="agreement" val="2" dense class="q-px-sm"/>
<span>
{{$t('accept_terms__section_checkbox_privacy') + ' '}}
<a href="">{{ $t('accept_terms__section_checkbox_privacy_doc') }}</a>
</span>
</div>
</div>
</div>
<div class="flex no-wrap justify-center w100 q-gutter-x-md">
<q-btn
flat
color="negative"
no-caps
style="opacity: 0.8"
@click="onDecline"
class="w40"
>
{{$t('accept_terms__btn_decline')}}
</q-btn>
<q-btn
flat
color="primary"
no-caps
:disable="agreement.length !== 2"
@click="onSubmit"
class="w40 fix-disabled"
>
{{$t('accept_terms__btn_agree')}}
</q-btn>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, inject } from 'vue'
import { parseStartParams } from 'helpers/helpers'
import BaseLogo from 'components/BaseLogo.vue'
import { useAuthStore } from 'stores/auth'
import { useRouter } from 'vue-router'
import type { WebApp } from '@twa-dev/types'
const tg = inject('tg') as WebApp
function onDecline () {
tg.close()
}
const authStore = useAuthStore()
const router = useRouter()
const startParams = typeof window !== 'undefined' && window.Telegram?.WebApp?.initDataUnsafe?.start_param
? parseStartParams(window.Telegram.WebApp.initDataUnsafe.start_param)
: null
async function onSubmit () {
await authStore.termsAccepted()
const route = startParams === null
? { name: '404' }
: startParams?.taskId
? { name: 'task_info', params: { id: startParams.id, taskId: startParams.taskId }}
: startParams?.meetingId
? { name: 'meeting_info', params: { id: startParams.id, meetingId: startParams.meetingId }}
: { name: 'files', params: { id: startParams.id }}
await router.push(route)
}
const agreement = ref([])
interface sizeParams {
height: number,
width: number
}
const sectionHeight = ref(0)
function onResize(size: sizeParams) {
sectionHeight.value = size.height
}
</script>
<style scoped lang="scss">
</style>

View File

@@ -3,78 +3,70 @@
<template #title> <template #title>
{{ $t('settings__title') }} {{ $t('settings__title') }}
</template> </template>
<pn-scroll-list> <pn-scroll-list>
<q-list separator> <q-list separator>
<q-item> <pn-item-btm-dialog
<q-item-section avatar> title="settings__language"
<q-avatar color="primary" rounded text-color="white" icon="mdi-translate" size="md" /> icon="mdi-translate"
</q-item-section> iconColor="primary"
<q-item-section> >
<span>{{ $t('settings__language') }}</span> <template #value>
</q-item-section> {{ localeOptions.find(el => el.value === locale)?.label }}
<q-item-section> </template>
<q-select <pn-list-selector
class="fix-input-right text-body1" v-model="locale"
v-model="locale" :options="localeOptions"
:options="localeOptions" />
dense </pn-item-btm-dialog>
borderless
emit-value <pn-item-btm-dialog
map-options title="settings__font_size"
hide-bottom-space icon="mdi-format-size"
/> iconColor="primary"
</q-item-section> >
</q-item> <template #value>
<q-item> {{ $t(fontSizeLabel) }}
<q-item-section avatar> </template>
<q-avatar color="primary" rounded text-color="white" icon="mdi-format-size" size="md" /> <pn-list-selector
</q-item-section> v-model="fontSize"
<q-item-section> :options="fontSizeOptions"
<span>{{ $t('settings__font_size') }}</span> />
</q-item-section> </pn-item-btm-dialog>
<q-item-section>
<div class="flex justify-end">
<q-btn
@click="settingsStore.decreaseFontSize()"
color="negative" flat
icon="mdi-format-font-size-decrease"
class="q-pa-sm q-mx-xs"
:disable="!settingsStore.canDecrease"
/>
<q-btn
@click="settingsStore.increaseFontSize()"
color="positive" flat
icon="mdi-format-font-size-increase"
class="q-pa-sm q-mx-xs"
:disable="!settingsStore.canIncrease"
/>
</div>
</q-item-section>
</q-item>
</q-list> </q-list>
</pn-scroll-list> </pn-scroll-list>
</pn-page-card> </pn-page-card>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, watch, ref, onMounted } from 'vue'
import { useSettingsStore } from 'stores/settings' import { useSettingsStore } from 'stores/settings'
import pnItemBtmDialog from 'components/pnItemBtmDialog.vue'
import pnListSelector from 'components/pnListSelector.vue'
const settingsStore = useSettingsStore() const settingsStore = useSettingsStore()
const locale = ref('')
const localeOptions = settingsStore.supportLocale const localeOptions = settingsStore.supportLocale
const locale = computed({ watch(locale, async (newValue) => {
get: () => settingsStore.settings.locale, await settingsStore.updateSettings({ locale: newValue })
// eslint-disable-next-line @typescript-eslint/no-misused-promises
set: (value: string) => settingsStore.updateLocale(value)
}) })
const fontSize = ref(16)
const fontSizeOptions = settingsStore.supportFontSizes
const fontSizeLabel = computed(() =>
fontSizeOptions.find(el => el.value === fontSize.value)?.label ?? ''
)
watch(fontSize, async (newValue) => {
await settingsStore.updateSettings({ fontSize: newValue })
})
onMounted(() => {
locale.value = settingsStore.settings.locale
fontSize.value = settingsStore.settings.fontSize
})
</script> </script>
<style scoped> <style scoped>
.fix-input-right :deep(.q-field__native) {
justify-content: end;
}
</style> </style>

View File

@@ -45,11 +45,15 @@
readonly readonly
filled filled
class="w100" class="w100"
:label = "$t('user_card__' + key)" :label = "(key!=='email' && key!=='phone') ? $t('user_card__' + key) : undefined"
> >
<template #control> <template #control>
{{displayUser[key]}} {{displayUser[key]}}
</template> </template>
<template #prepend v-if="(key==='email' || key==='phone')">
<q-icon v-if="key==='email'" name="mdi-email-outline"/>
<q-icon v-if="key==='phone'" name="mdi-phone-outline"/>
</template>
</q-field> </q-field>
</div> </div>
@@ -73,15 +77,13 @@
const userId = parseIntString(route.params.userId) const userId = parseIntString(route.params.userId)
const user = computed(() => userId && usersStore.userById(userId)) const user = computed(() => userId && usersStore.userById(userId))
const tname = computed(() => { const tname = computed(() =>
return (!user.value) user.value
? '' ? [user.value?.firstname, user.value?.lastname]
: user.value.firstname .filter(Boolean)
? user.value.lastname .join(' ')
? user.value.firstname + ' ' + user.value.lastname : ''
: user.value.firstname )
: user.value.lastname ?? ''
})
const userPosition = computed(() => { const userPosition = computed(() => {
return (!user.value) return (!user.value)

View File

@@ -2,21 +2,12 @@
<div class="q-pa-none flex column col-grow no-scroll"> <div class="q-pa-none flex column col-grow no-scroll">
<pn-scroll-list> <pn-scroll-list>
<template #card-body-header> <template #card-body-header>
<pn-action-bar
<div class="flex row q-ma-md justify-between"> v-model="search"
<q-input placeholder="chats__search"
v-model="search" :show-filter-btn="false"
clearable :show-calendar-btn="false"
clear-icon="close" />
:placeholder="$t('chats__search')"
dense
class="col-grow"
>
<template #prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
</div>
</template> </template>
<q-list separator> <q-list separator>
<q-item <q-item
@@ -59,6 +50,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, inject } from 'vue' import { ref, computed, inject } from 'vue'
import { useChatsStore } from 'stores/chats' import { useChatsStore } from 'stores/chats'
import pnActionBar from 'components/pnActionBar.vue'
import type { WebApp } from '@twa-dev/types' import type { WebApp } from '@twa-dev/types'
const tg = inject('tg') as WebApp const tg = inject('tg') as WebApp

View File

@@ -2,60 +2,16 @@
<div class="q-pa-none flex column col-grow no-scroll"> <div class="q-pa-none flex column col-grow no-scroll">
<pn-scroll-list> <pn-scroll-list>
<template #card-body-header> <template #card-body-header>
<pn-action-bar
<div class="flex row q-mb-xs q-mt-md q-mx-sm justify-between"> v-model="search"
<q-btn placeholder="files__search"
icon="mdi-calendar-month-outline" :calendar-active="showCalendar"
flat dense round :filter-active="showFiltersDialog"
class="q-mr-sm" :calendar-badge="!!datesRange"
size="lg" :filter-badge="!checkFiltersSelect"
:color="showCalendar ? 'primary' : 'grey'" @toggle-calendar="showCalendar = !showCalendar"
@click="showCalendar = !showCalendar" @open-filters="showFiltersDialog = true"
> />
<div>
<q-badge
color="red"
rounded
floating
transparent
style="position: relative; top: -16px; margin-left: -12px"
:style="{ opacity: datesRange ? 0.8 : 0 }"
/>
</div>
</q-btn>
<q-input
v-model="search"
clearable
clear-icon="close"
:placeholder="$t('files__search')"
dense
class="col-grow"
>
<template #prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
<q-btn
@click="showFiltersDialog = true"
icon="mdi-filter-outline"
dense round flat
size="lg"
:color="showFiltersDialog ? 'primary' : 'grey'"
class="q-mr-xs"
>
<div>
<q-badge
color="red"
rounded
floating
transparent
style="position: relative; top: -16px; margin-left: -12px"
:style="{ opacity: !checkFiltersSelect ? 0.8 : 0 }"
/>
</div>
</q-btn>
</div>
<q-slide-transition> <q-slide-transition>
<div v-show="showCalendar"> <div v-show="showCalendar">
<q-date <q-date
@@ -241,6 +197,7 @@
import { useFilesStore } from 'stores/files' import { useFilesStore } from 'stores/files'
import { useUsersStore } from 'stores/users' import { useUsersStore } from 'stores/users'
import { useChatsStore } from 'stores/chats' import { useChatsStore } from 'stores/chats'
import pnActionBar from 'components/pnActionBar.vue'
import { date } from 'quasar' import { date } from 'quasar'
import { parseFileName, fileIcon, fileSize } from 'helpers/files-functions' import { parseFileName, fileIcon, fileSize } from 'helpers/files-functions'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'

View File

@@ -2,41 +2,14 @@
<div class="q-pa-none flex column col-grow no-scroll"> <div class="q-pa-none flex column col-grow no-scroll">
<pn-scroll-list> <pn-scroll-list>
<template #card-body-header> <template #card-body-header>
<pn-action-bar
<div class="flex row q-mb-xs q-mt-md q-mx-sm justify-between"> v-model="search"
<q-btn placeholder="meetings__search"
icon="mdi-calendar-month-outline" :calendar-active="showCalendar"
flat dense round :calendar-badge="!!datesRange"
class="q-mr-sm" @toggle-calendar="showCalendar = !showCalendar"
size="lg" :show-filter-btn="false"
:color="showCalendar ? 'primary' : 'grey'" />
@click="showCalendar = !showCalendar"
>
<div>
<q-badge
color="red"
rounded
floating
transparent
style="position: relative; top: -16px; margin-left: -12px"
:style="{ opacity: datesRange ? 0.8 : 0 }"
/>
</div>
</q-btn>
<q-input
v-model="search"
clearable
clear-icon="close"
:placeholder="$t('meetings__search')"
dense
class="col-grow"
>
<template #prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
</div>
<q-slide-transition> <q-slide-transition>
<div v-show="showCalendar"> <div v-show="showCalendar">
<q-date <q-date
@@ -65,7 +38,7 @@
<q-btn flat dense no-caps v-if ="showReset" @click="resetDisplayPreviousMeetings"> <q-btn flat dense no-caps v-if ="showReset" @click="resetDisplayPreviousMeetings">
<div class="flex items-center text-caption text-grey"> <div class="flex items-center text-caption text-grey">
{{$t('meetings__previous_hide')}} {{$t('meetings__previous_hide')}}
<q-icon name="close" size="xs"/> <q-icon name="mdi-close" size="xs"/>
</div> </div>
</q-btn> </q-btn>
</div> </div>
@@ -227,6 +200,7 @@
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import type { Meeting } from 'types/Meeting' import type { Meeting } from 'types/Meeting'
import { date } from 'quasar' import { date } from 'quasar'
import pnActionBar from 'components/pnActionBar.vue'
const search = ref('') const search = ref('')
const showCalendar = ref<boolean>(false) const showCalendar = ref<boolean>(false)

View File

@@ -2,60 +2,16 @@
<div class="q-pa-none flex column col-grow no-scroll"> <div class="q-pa-none flex column col-grow no-scroll">
<pn-scroll-list> <pn-scroll-list>
<template #card-body-header> <template #card-body-header>
<pn-action-bar
<div class="flex row q-mb-xs q-mt-md q-mx-sm justify-between"> v-model="search"
<q-btn placeholder="tasks__search"
icon="mdi-calendar-month-outline" :calendar-active="showCalendar"
flat dense round :filter-active="showFiltersDialog"
class="q-mr-sm" :calendar-badge="!!datesRange"
size="lg" :filter-badge="!checkFiltersSelect"
:color="showCalendar ? 'primary' : 'grey'" @toggle-calendar="showCalendar = !showCalendar"
@click="showCalendar = !showCalendar" @open-filters="showFiltersDialog = true"
> />
<div>
<q-badge
color="red"
rounded
floating
transparent
style="position: relative; top: -16px; margin-left: -12px"
:style="{ opacity: datesRange ? 0.8 : 0 }"
/>
</div>
</q-btn>
<q-input
v-model="search"
clearable
clear-icon="close"
:placeholder="$t('tasks__search')"
dense
class="col-grow"
>
<template #prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
<q-btn
@click="showFiltersDialog = true"
icon="mdi-filter-outline"
dense round flat
size="lg"
:color="showFiltersDialog ? 'primary' : 'grey'"
class="q-mr-xs"
>
<div>
<q-badge
color="red"
rounded
floating
transparent
style="position: relative; top: -16px; margin-left: -12px"
:style="{ opacity: !checkFiltersSelect ? 0.8 : 0 }"
/>
</div>
</q-btn>
</div>
<q-slide-transition> <q-slide-transition>
<div v-show="showCalendar"> <div v-show="showCalendar">
<q-date <q-date
@@ -236,6 +192,7 @@
import taskItem from 'components/taskItem.vue' import taskItem from 'components/taskItem.vue'
import type { Task } from 'types/Task' import type { Task } from 'types/Task'
import { date } from 'quasar' import { date } from 'quasar'
import pnActionBar from 'components/pnActionBar.vue'
const search = ref('') const search = ref('')
const showCalendar = ref<boolean>(false) const showCalendar = ref<boolean>(false)

View File

@@ -2,20 +2,12 @@
<div class="q-pa-none flex column col-grow no-scroll"> <div class="q-pa-none flex column col-grow no-scroll">
<pn-scroll-list> <pn-scroll-list>
<template #card-body-header> <template #card-body-header>
<div class="flex row q-ma-md justify-between"> <pn-action-bar
<q-input v-model="search"
v-model="search" placeholder="users__search"
clearable :show-filter-btn="false"
clear-icon="close" :show-calendar-btn="false"
:placeholder="$t('users__search')" />
dense
class="col-grow"
>
<template #prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
</div>
</template> </template>
<q-list separator> <q-list separator>
@@ -57,6 +49,7 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useUsersStore } from 'stores/users' import { useUsersStore } from 'stores/users'
import pnActionBar from 'components/pnActionBar.vue'
import type { User } from 'types/User' import type { User } from 'types/User'
const router = useRouter() const router = useRouter()

View File

@@ -8,45 +8,51 @@ import {
import routes from './routes' import routes from './routes'
import { useProjectsStore } from 'stores/projects' import { useProjectsStore } from 'stores/projects'
import { useAuthStore } from 'stores/auth' import { useAuthStore } from 'stores/auth'
import { parseStartParams } from 'helpers/helpers'
export default defineRouter(function (/* { store, ssrContext } */) { export default defineRouter(function (/* { store, ssrContext } */) {
const createHistory = process.env.SERVER const createHistory = process.env.SERVER
? createMemoryHistory ? createMemoryHistory
: (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory) : (process.env.VUE_ROUTER_MODE === 'history' ? createWebHistory : createWebHashHistory)
const Router = createRouter({ const startRouteInfo = typeof window !== 'undefined' && window.Telegram?.WebApp?.initDataUnsafe?.start_param
? parseStartParams(window.Telegram.WebApp.initDataUnsafe.start_param)
: null
const Router = createRouter({
scrollBehavior: () => ({ left: 0, top: 0 }), scrollBehavior: () => ({ left: 0, top: 0 }),
routes, routes,
// Leave this as is and make changes in quasar.conf.js instead! // Leave this as is and make changes in quasar.conf.js instead!
// quasar.conf.js -> build -> vueRouterMode // quasar.conf.js -> build -> vueRouterMode
// quasar.conf.js -> build -> publicPath // quasar.conf.js -> build -> publicPath
history: createHistory(process.env.VUE_ROUTER_BASE), history: createHistory(process.env.VUE_ROUTER_BASE)
}) })
Router.beforeEach(async (to) => { Router.beforeEach(async (to) => {
console.log(window.Telegram.WebApp.initDataUnsafe.start_param, startRouteInfo)
if (to.name === 'settings') return console.log(112, to)
if (to.name === '404') return if (to.name === 'settings' || to.name === '404' || to.name === 'accept-terms') return true
const authStore = useAuthStore()
const authStore = useAuthStore()
const projectsStore = useProjectsStore() const projectsStore = useProjectsStore()
if (authStore.startRouteInfo && to.path === '/') { if (!authStore.isInit) await authStore.init(window.Telegram.WebApp)
const { id, taskId, meetingId } = authStore.startRouteInfo // if (!authStore.isTermsAccepted) return { name: 'accept-terms' }
authStore.setStartRouteInfo(null)
if (!projectsStore.isInit) await projectsStore.init()
const project = projectsStore.projectById(id)
if (!project) return { name: '404' } if (to.path === '/' && startRouteInfo) {
console.log(222, startRouteInfo)
const { id, taskId, meetingId } = startRouteInfo
return taskId if (!projectsStore.isInit) await projectsStore.init()
? { name: 'task_info', params: { id, taskId } }
: meetingId const project = projectsStore.projectById(id)
? { name: 'meeting_info', params: { id, meetingId } } if (!project) return { name: '404' }
: { name: 'files', params: { id } }
return taskId
? { name: 'task_info', params: { id, taskId } }
: meetingId
? { name: 'meeting_info', params: { id, meetingId } }
: { name: 'files', params: { id } }
} }
if (to.params.id) { if (to.params.id) {

View File

@@ -85,6 +85,12 @@ const routes: RouteRecordRaw[] = [
} }
] ]
}, },
{
name: 'accept-terms',
path: '/accept-terms-of-use',
component: () => import('pages/AcceptTermsPage.vue'),
meta: { hideBackButton: true }
},
{ {
name: '404', name: '404',
path: '/:catchAll(.*)*', path: '/:catchAll(.*)*',

View File

@@ -5,25 +5,32 @@ import type { WebApp } from '@twa-dev/types'
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const isInit = ref(false) const isInit = ref(false)
const isTermsAccepted = ref(false)
const telegramUserData = ref() const telegramUserData = ref()
async function init (tg: WebApp) { async function init (tg: WebApp) {
await api.post('/auth?' + tg?.initData) const { data } = await api.post('/auth?' + tg?.initData)
telegramUserData.value = tg?.initDataUnsafe.user telegramUserData.value = tg?.initDataUnsafe.user
isTermsAccepted.value = data.data?.is_terms_accepted
isInit.value = true isInit.value = true
} }
const startRouteInfo = ref<{ id: number; taskId?: number; meetingId?: number } | null>(null) async function termsAccepted () {
const { data } = await api.post('/terms/accept')
if (data.success) isTermsAccepted.value = true
}
function setStartRouteInfo (info: { id: number; taskId?: number; meetingId?: number } | null) { async function termsRevoked () {
startRouteInfo.value = info const { data } = await api.post('/terms/revoke')
if (data.success) isTermsAccepted.value = false
} }
return { return {
isInit, isInit,
isTermsAccepted,
telegramUserData, telegramUserData,
startRouteInfo, init,
setStartRouteInfo, termsAccepted,
init termsRevoked
} }
}) })

View File

@@ -11,9 +11,6 @@ interface AppSettings {
} }
const defaultFontSize = 16 const defaultFontSize = 16
const minFontSize = 10
const maxFontSize = 22
const fontSizeStep = 2
const defaultSettings: AppSettings = { const defaultSettings: AppSettings = {
fontSize: defaultFontSize, fontSize: defaultFontSize,
@@ -29,14 +26,18 @@ export const useSettingsStore = defineStore('settings', () => {
const isInit = ref(false) const isInit = ref(false)
const currentFontSize = computed(() => settings.value?.fontSize ?? defaultFontSize) const currentFontSize = computed(() => settings.value?.fontSize ?? defaultFontSize)
const canIncrease = computed(() => currentFontSize.value < maxFontSize)
const canDecrease = computed(() => currentFontSize.value > minFontSize)
const supportLocale = [ const supportLocale = [
{ value: 'en-US', label: 'English' }, { value: 'en-US', label: 'English' },
{ value: 'ru-RU', label: 'Русский' } { value: 'ru-RU', label: 'Русский' }
] ]
const supportFontSizes = [
{ value: 12, label: 'settings__fontsize_small' },
{ value: 16, label: 'settings__fontsize_medium' },
{ value: 20, label: 'settings__fontsize_large' }
]
const quasarLangMap: Record<string, string> = { const quasarLangMap: Record<string, string> = {
'en-US': 'en-US', 'en-US': 'en-US',
'ru-RU': 'ru' 'ru-RU': 'ru'
@@ -89,32 +90,12 @@ export const useSettingsStore = defineStore('settings', () => {
} }
} }
const updateLocale = async (newLocale: string) => {
if (i18nLocale) {
i18nLocale.value = newLocale
await updateQuasarLang(newLocale)
settings.value.locale = newLocale
await saveSettings()
}
}
const saveSettings = async () => {
await api.put('/settings', { settings: settings.value })
}
const updateSettings = async (newSettings: Partial<AppSettings>) => {
settings.value = { ...settings.value, ...newSettings }
updateCssVariable()
await applyLocale()
await saveSettings()
}
const init = async () => { const init = async () => {
try { try {
const { data } = await api.get('/settings') const { data } = await api.get('/settings')
settings.value = { settings.value = {
fontSize: data.data.settings.fontSize || defaultSettings.fontSize, fontSize: data.data.fontSize || defaultSettings.fontSize,
locale: data.data.settings.locale || detectLocale() locale: data.data.locale || detectLocale()
} }
} catch { } catch {
settings.value.locale = detectLocale() settings.value.locale = detectLocale()
@@ -124,30 +105,25 @@ export const useSettingsStore = defineStore('settings', () => {
isInit.value = true isInit.value = true
} }
const clampFontSize = (size: number) => const saveSettings = async () => {
Math.max(minFontSize, Math.min(size, maxFontSize)) await api.put('/settings', settings.value)
const increaseFontSize = async () => {
const newSize = clampFontSize(currentFontSize.value + fontSizeStep)
await updateSettings({ fontSize: newSize })
} }
const decreaseFontSize = async () => { const updateSettings = async (newSettings: Partial<AppSettings>) => {
const newSize = clampFontSize(currentFontSize.value - fontSizeStep) settings.value = { ...settings.value, ...newSettings }
await updateSettings({ fontSize: newSize }) updateCssVariable()
await applyLocale()
await saveSettings()
} }
return { return {
settings, settings,
supportLocale, supportLocale,
supportFontSizes,
isInit, isInit,
currentFontSize, currentFontSize,
canIncrease,
canDecrease,
init, init,
increaseFontSize, updateSettings
decreaseFontSize,
updateSettings,
updateLocale
} }
}) })