This commit is contained in:
vadim committed 2026-09-24 15:08:46 +03:00
commit fcec12e5ba
78 files changed
+9569

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts"></script>
<template>
<div>Home</div>
</template>
<style scoped lang="scss"></style>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { ref, watchEffect } from 'vue'
import { useIssueStore } from '@/stores/issue.ts'
import IssueDetails from '@/components/IssueDetails.vue'
import type { Issue } from '@/api/issue.api.ts'
interface Props {
ckey: string
}
const issueStore = useIssueStore()
const issue = ref<Issue>()
const { ckey } = defineProps<Props>()
async function fetch() {
const res = await issueStore.getByKey(ckey)
if (!res) return
issue.value = res
}
watchEffect(async () => fetch())
</script>
<template>
<v-sheet border>
<issue-details :ckey="ckey" />
</v-sheet>
</template>
<style scoped lang="scss"></style>
+258
View File
@@ -0,0 +1,258 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useIssueStore } from '@/stores/issue'
import { useProjectStore } from '@/stores/project.ts'
import { useRoute, useRouter } from 'vue-router'
import IssueDetails from '@/components/IssueDetails.vue'
import IssueCreate from '@/components/IssueCreate.vue'
import type { Issue } from '@/api/issue.api.ts'
import IssuesListElement from '@/components/IssuesListElement.vue'
import IssueFilter from '@/components/IssueFilter.vue'
import IssueStats from '@/components/IssueStats.vue'
const issueStore = useIssueStore()
const issueDetailsRef = ref<InstanceType<typeof IssueDetails>>()
const projectStore = useProjectStore()
const { currentProjectId } = storeToRefs(projectStore)
const route = useRoute()
const router = useRouter()
const itemsPerPage = ref(10)
const page = ref(1)
const totalItems = ref(0)
const totalPages = ref(1)
const serverItems = ref<Issue[]>([])
const text = ref<string | null>(null)
const selected = ref<[string] | []>([])
const batch = ref<number[]>([])
const deleting = ref(false)
async function fetch() {
const res = await issueStore.list({
page: page.value,
limit: itemsPerPage.value,
text: text.value,
projectId: currentProjectId.value,
})
serverItems.value = [...res.list]
totalPages.value = res.meta.totalPages
totalItems.value = res.meta.totalCount
}
async function load() {
syncQuery()
await fetch()
}
// async function onSearch() {
// await updatePage(1)
// }
function syncQuery() {
const query: Record<string, string> = { page: String(page.value) }
if (text.value) query.text = text.value
if (selected.value.length) query.selected_issue = selected.value[0]
router.replace({ query })
}
watch(
() => route.query,
(query) => {
page.value = query.page ? Number(query.page) : 1
text.value = query.text ? String(query.text) : null
selected.value = query.selected_issue ? [String(query.selected_issue)] : []
},
{ immediate: true },
)
watch(selected, () => syncQuery())
watch(currentProjectId, async () => {
page.value = 1
clearBatch()
close()
await load()
})
onMounted(async () => await load())
const gridTemplate = computed(() => (selected.value.length ? '1fr 1fr' : '1fr'))
const goToIssue = (key: string[]) => router.push(`/issues/${key[0]}`)
async function updatePage(val: number) {
page.value = val
await load()
}
function close() {
selected.value = []
}
function onIssueUpdated(updated: Issue) {
const idx = serverItems.value.findIndex((el) => el.id === updated.id)
if (idx !== -1) {
serverItems.value.splice(idx, 1, updated)
}
issueDetailsRef.value?.applyUpdate(updated)
}
async function onIssueCreated(created: Issue) {
await fetch()
if (selected.value.length) {
selected.value = [created.key]
}
}
function toggleBatchSelect(id: number) {
const idx = batch.value.indexOf(id)
if (idx === -1) batch.value.push(id)
else batch.value.splice(idx, 1)
}
function clearBatch() {
batch.value = []
}
const allVisibleSelected = computed(
() => serverItems.value.length > 0 && serverItems.value.every((el) => batch.value.includes(el.id)),
)
const someVisibleSelected = computed(() => serverItems.value.some((el) => batch.value.includes(el.id)))
function toggleSelectAllVisible() {
if (allVisibleSelected.value) {
const visibleIds = new Set(serverItems.value.map((el) => el.id))
batch.value = batch.value.filter((id) => !visibleIds.has(id))
} else {
batch.value = [...new Set([...batch.value, ...serverItems.value.map((el) => el.id)])]
}
}
async function onDeleteBatch() {
deleting.value = true
try {
await Promise.all(batch.value.map((id) => issueStore.remove(id)))
batch.value = []
close()
await fetch()
} finally {
deleting.value = false
}
}
</script>
<template>
<div class="d-flex flex-column ga-2">
<div class="u-grid ga-1">
<div class="u-height">
<v-list class="pa-0" density="compact" v-model:selected="selected" mandatory>
<!-- <issue-filter />-->
<v-list-item>
<template #prepend>
<div class="mr-2" @click.stop>
<v-checkbox
:model-value="allVisibleSelected"
:indeterminate="someVisibleSelected && !allVisibleSelected"
@update:model-value="toggleSelectAllVisible"
/>
</div>
</template>
<template #title>
<div>
<div>({{ serverItems.length }} of {{ totalItems }} elements visible)</div>
</div>
</template>
<template #append>
<issue-stats />
</template>
</v-list-item>
<issue-create @created="onIssueCreated" />
<issues-list-element
v-for="el in serverItems"
:key="el.id"
:issue="el"
:selected="batch.includes(el.id)"
@status-changed="onIssueUpdated"
@priority-changed="onIssueUpdated"
@assignee-changed="onIssueUpdated"
@toggle-select="toggleBatchSelect"
/>
</v-list>
<div class="d-flex justify-center pa-2">
<v-pagination
:model-value="page"
:length="totalPages"
:total-visible="7"
density="comfortable"
@update:model-value="updatePage"
/>
</div>
<div v-if="batch.length" class="d-flex justify-center ga-2">
<v-banner max-width="30rem" border lines="one">
<div class="d-flex align-center ga-2">
<div>{{ batch.length }} selected</div>
<v-divider vertical />
<v-btn
prepend-icon="mdi-trash-can"
density="compact"
variant="tonal"
:loading="deleting"
@click="onDeleteBatch"
>
Delete
</v-btn>
<v-btn prepend-icon="mdi-circle" density="compact" variant="tonal"> Done </v-btn>
</div>
<template #actions>
<div class="d-flex align-center ga-2">
<v-divider vertical />
<v-btn density="comfortable" icon="mdi-close" @click="clearBatch"></v-btn>
</div>
</template>
</v-banner>
</div>
</div>
<v-sheet border v-if="selected.length" class="overflow-y-auto">
<div class="d-flex align-center ga-2 px-4 py-2">
<div>Work item</div>
<v-spacer />
<v-icon-btn icon="mdi-open-in-new" @click="goToIssue(selected)" />
<v-icon-btn icon="mdi-close" @click="close" />
</div>
<v-divider />
<issue-details
ref="issueDetailsRef"
:ckey="selected[0]"
@status-changed="onIssueUpdated"
@assignee-changed="onIssueUpdated"
/>
</v-sheet>
</div>
</div>
</template>
<style scoped lang="scss">
.u-grid {
display: grid;
grid-template-columns: v-bind(gridTemplate);
}
.u-height {
height: calc(100vh - 6rem);
}
</style>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts"></script>
<template>
<div>Games</div>
</template>
<style scoped lang="scss"></style>
+115
View File
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
const cards = ref<string[]>([])
const size = ref(2)
const data = ['1', '2', '3', '4', '5', '6']
const result = ref(new Map<string, [string, number]>())
const currentCheck = ref(new Map<string, [string, number]>())
const checking = ref(false)
const startDate = ref<Date>()
const timeout = ref<number>(0)
const generate = () => {
checking.value = false
result.value.clear()
currentCheck.value.clear()
const arr = Array.from({ length: size.value }, () => data).flat()
cards.value = shuffle(arr)
startDate.value = new Date()
}
function shuffle(array: string[]) {
const arr = array.slice()
let i = arr.length
let j = 0
let temp: string
while (--i > 0) {
j = Math.floor(Math.random() * (i + 1))
temp = arr[j]!
arr[j] = arr[i]!
arr[i] = temp
}
return arr
}
function compareCards(dataMap: Map<string, [string, number]>) {
const set = new Set()
dataMap.forEach((el) => set.add(el[0]))
return set.size === 1
}
function addCorrect(dataMap: Map<string, [string, number]>) {
for (const [key, value] of dataMap) {
result.value.set(key, value)
}
}
function checkCard(card: string, idx: number) {
if (checking.value) return
if (currentCheck.value.has(`${card}:${idx}`)) return
currentCheck.value.set(`${card}:${idx}`, [card, idx])
if (currentCheck.value.size === size.value) {
checking.value = true
const ok = compareCards(currentCheck.value)
if (ok) {
addCorrect(currentCheck.value)
currentCheck.value.clear()
checking.value = false
} else {
timeout.value = setTimeout(() => {
currentCheck.value.clear()
checking.value = false
}, 1000)
}
}
}
const isWin = computed(() => data.length === result.value.size / size.value)
const roundTime = computed(() => {
const endDate = new Date()
return isWin.value && startDate.value ? endDate.valueOf() - startDate.value.valueOf() / 1000 : null
})
function checkPressed(card: string, idx: number) {
return result.value.has(`${card}:${idx}`) || currentCheck.value.has(`${card}:${idx}`)
}
onUnmounted(() => {})
</script>
<template>
<div class="d-flex flex-column ga-2">
<div class="d-flex ga-2 align-center">
<v-btn height="40px" @click="generate">Generate</v-btn>
<v-text-field v-model="size" />
</div>
<div v-if="cards.length" class="field rounded">
<div class="card" v-for="(card, idx) in cards" :key="idx">
<v-img v-if="checkPressed(card, idx)" :src="`${card}.jpeg`" rounded />
<v-img v-else src="back.jpeg" @click="checkCard(card, idx)" rounded />
</div>
</div>
<v-alert v-if="isWin" :text="`Congratulations ${roundTime} sec`" type="success"></v-alert>
</div>
</template>
<style scoped lang="scss">
.field {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 0.5rem;
width: 50rem;
}
</style>
+208
View File
@@ -0,0 +1,208 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useProjectStore } from '@/stores/project.ts'
import type { Project } from '@/api/project.api.ts'
import { authClient } from '@/lib/auth-client.ts'
const projectStore = useProjectStore()
const router = useRouter()
const session = authClient.useSession()
function isOwner(project: Project) {
return project.ownerId === session.value.data?.user.id
}
const itemsPerPage = ref(10)
const page = ref(1)
const totalItems = ref(0)
const totalPages = ref(0)
const serverItems = ref<Project[]>([])
const loading = ref(false)
const headers = [
{ key: 'key', title: 'Key', sortable: false },
{ key: 'name', title: 'Name', sortable: false },
{ key: 'createdAt', title: 'Created', sortable: false },
{ key: 'actions', title: '', sortable: false, align: 'end' as const },
]
async function fetch() {
loading.value = true
try {
const res = await projectStore.list({ page: page.value, limit: itemsPerPage.value })
serverItems.value = res.list
totalItems.value = res.meta.totalCount
totalPages.value = res.meta.totalPages
} finally {
loading.value = false
}
}
async function loadItems(options: { page: number; itemsPerPage: number }) {
page.value = options.page
itemsPerPage.value = options.itemsPerPage
await fetch()
}
async function updatePage(val: number) {
page.value = val
await fetch()
}
// CREATE / EDIT DIALOG
const showDialog = ref(false)
const saving = ref(false)
const editingProject = ref<Project | null>(null)
const form = reactive({ key: '', name: '' })
function openCreate() {
editingProject.value = null
form.key = ''
form.name = ''
projectStore.clearError()
showDialog.value = true
}
function openEdit(project: Project) {
editingProject.value = project
form.key = project.key
form.name = project.name
projectStore.clearError()
showDialog.value = true
}
function closeDialog() {
showDialog.value = false
editingProject.value = null
projectStore.clearError()
}
async function onSubmit() {
if (!form.key || !form.name) return
projectStore.clearError()
saving.value = true
try {
if (editingProject.value) {
const updated = await projectStore.update(editingProject.value.id, { name: form.name })
if (!updated) return
if (projectStore.current?.id === updated.id) projectStore.select(updated)
} else {
const created = await projectStore.create({ key: form.key.toUpperCase(), name: form.name })
if (!created) return
}
closeDialog()
await fetch()
} finally {
saving.value = false
}
}
function openIssues(project: Project) {
projectStore.select(project)
router.push('/issues')
}
// DELETE
const deletingId = ref<string | null>(null)
async function onDelete(project: Project) {
deletingId.value = project.id
try {
const ok = await projectStore.remove(project.id)
if (!ok) return
if (projectStore.current?.id === project.id) projectStore.select(null)
await fetch()
} finally {
deletingId.value = null
}
}
</script>
<template>
<div class="d-flex flex-column ga-2">
<div class="d-flex justify-end">
<v-btn prepend-icon="mdi-plus" text="Create project" @click="openCreate" />
</div>
<v-data-table-server
density="compact"
hover
hide-default-footer
:loading="loading"
:headers="headers"
:items-per-page="itemsPerPage"
:page="page"
:items="serverItems"
:items-length="totalItems"
item-value="id"
@update:options="loadItems"
@click:row="(_e: unknown, { item }: { item: Project }) => openIssues(item)"
>
<template #[`item.createdAt`]="{ item }">
{{ new Date(item.createdAt).toLocaleDateString('ru-RU') }}
</template>
<template #[`item.actions`]="{ item }">
<div v-if="isOwner(item)" class="d-flex justify-end ga-1">
<v-icon-btn density="compact" icon="mdi-pencil" @click.stop="openEdit(item)" />
<v-icon-btn
color="error"
variant="text"
density="compact"
icon="mdi-trash-can"
:loading="deletingId === item.id"
@click.stop="onDelete(item)"
/>
</div>
</template>
</v-data-table-server>
<v-pagination
:model-value="page"
:length="totalPages"
:total-visible="5"
density="comfortable"
@update:model-value="updatePage"
/>
<v-dialog v-model="showDialog" max-width="35rem">
<v-card :title="editingProject ? 'Edit project' : 'Create project'">
<v-card-text class="d-flex flex-column ga-4">
<v-alert
v-if="projectStore.error"
type="error"
density="compact"
variant="flat"
closable
@click:close="projectStore.clearError()"
>
{{ projectStore.error }}
</v-alert>
<v-text-field v-model="form.key" label="Key" :disabled="!!editingProject" autofocus />
<v-text-field v-model="form.name" label="Name" @keyup.enter="onSubmit" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" text="Cancel" @click="closeDialog" />
<v-btn variant="tonal" text="Save" :loading="saving" @click="onSubmit" />
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<style scoped lang="scss">
:deep(tbody tr) {
cursor: pointer;
}
</style>
+96
View File
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { ref, watch, watchEffect } from 'vue'
import { type Item, useIssueStore } from '@/stores/issue'
import { useRoute, useRouter } from 'vue-router'
const issueStore = useIssueStore()
const route = useRoute()
const router = useRouter()
const itemsPerPage = ref(10)
const page = ref(1)
const text = ref<string | null>(null)
const serverItems = ref<Item[]>([])
const loading = ref(true)
const totalItems = ref(0)
async function fetch() {
syncQuery()
loading.value = true
const res = await issueStore.list({
page: page.value,
itemsPerPage: itemsPerPage.value,
text: text.value,
})
serverItems.value = res.items
totalItems.value = res.total
loading.value = false
}
async function loadItems(params: { page: number; itemsPerPage: number; sortBy?: unknown }) {
page.value = params.page
itemsPerPage.value = params.itemsPerPage
await fetch()
}
async function onSearch() {
page.value = 1
await fetch()
}
function syncQuery() {
const query: Record<string, string> = {
page: String(page.value),
}
if (text.value) query.text = text.value
router.replace({ query })
}
watch(
() => route.query,
(query) => {
page.value = query.page ? Number(query.page) : 1
text.value = query.text ? String(query.text) : null
},
{ immediate: true },
)
</script>
<template>
<div class="d-flex flex-column ga-2">
<v-text-field
v-model="text"
clearable
variant="outlined"
density="compact"
placeholder="Search name..."
hide-details
@keydown.enter.prevent="onSearch"
@click:clear="onSearch"
/>
<v-data-table-server
density="compact"
hide-default-header
hover
:headers="[
{ key: 'key', title: 'key' },
{ key: 'text', title: 'text' },
{ key: 'status', title: 'status' },
{ key: 'priority', title: 'priority' },
{ key: 'assignee', title: 'assignee' },
]"
:items-per-page="itemsPerPage"
:items-per-page-options="[itemsPerPage]"
:page="page"
:items="serverItems"
:items-length="totalItems"
item-value="text"
@update:options="loadItems"
/>
</div>
</template>
@@ -0,0 +1,7 @@
<script setup lang="ts"></script>
<template>
<div>general settings</div>
</template>
<style scoped lang="scss"></style>
+24
View File
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { ref } from 'vue'
const tab = ref(0)
</script>
<template>
<div>
<div class="text-uppercase text-title-large mb-4">Settings</div>
<v-sheet class="mb-2">
<v-tabs v-model="tab">
<v-tab value="common" to="/settings">General</v-tab>
<v-tab value="projects" to="/settings/projects">Projects</v-tab>
</v-tabs>
<div class="pa-6">
<router-view />
</div>
</v-sheet>
</div>
</template>
<style scoped lang="scss"></style>
+5
View File
@@ -0,0 +1,5 @@
<script setup lang="ts"></script>
<template>summary</template>
<style scoped lang="scss"></style>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { authClient } from '@/lib/auth-client.js'
import SessionsList from '@/components/SessionsList.vue'
import UserProfile from '@/components/UserProfile.vue'
import PasswordChange from '@/components/PasswordChange.vue'
const session = authClient.useSession()
</script>
<template>
<v-sheet rounded border class="pa-4 d-flex flex-column ga-4">
<div class="text-headline-medium text-uppercase">profile</div>
<user-profile :user="session.data?.user" />
<password-change />
<sessions-list />
</v-sheet>
</template>
<style scoped lang="scss"></style>
+44
View File
@@ -0,0 +1,44 @@
<script setup lang="ts">
import { reactive } from 'vue'
import { authClient } from '@/lib/auth-client.js'
const signInData = reactive({
email: 'vadim.olonin@gmail.com',
password: '',
rememberMe: true,
callbackURL: 'https://madsky.ru',
})
const signIn = async () => {
const { data, error } = await authClient.signIn.email({
email: signInData.email,
password: signInData.password,
rememberMe: signInData.rememberMe,
callbackURL: '/',
})
console.log(data)
console.log(error)
}
</script>
<template>
<v-row>
<v-col cols="4" offset="4">
<div class="d-flex flex-column ga-2">
<div class="text-uppercase">sign in</div>
<v-text-field prepend-inner-icon="mdi-email" v-model="signInData.email" />
<v-text-field prepend-inner-icon="mdi-lock" v-model="signInData.password" />
<div class="d-flex ga-2 align-center">
<v-switch color="info" v-model="signInData.rememberMe" label="Remember me" />
<v-spacer />
<div>
<v-btn @click="signIn">login</v-btn>
</div>
</div>
</div>
</v-col>
</v-row>
</template>
<style scoped lang="scss"></style>
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
import { authClient } from '@/lib/auth-client.js'
import { reactive } from 'vue'
const signUpData = reactive({
name: 'Vadim',
email: 'vadim.olonin@gmail.com',
password: '',
})
const signUp = async () => {
const { data, error } = await authClient.signUp.email({
name: signUpData.name,
email: signUpData.email,
password: signUpData.password,
// image: 'https://example.com/image.png',
callbackURL: 'https://madsky.ru',
})
console.log(data)
console.log(error)
}
</script>
<template>
<v-row>
<v-col cols="4" offset="4">
<div class="d-flex flex-column ga-2">
<div class="text-uppercase">sign up</div>
<v-text-field placeholder="name" prepend-inner-icon="mdi-account" v-model="signUpData.name" />
<v-text-field placeholder="email" prepend-inner-icon="mdi-email" v-model="signUpData.email" />
<v-text-field placeholder="password" prepend-inner-icon="mdi-lock" v-model="signUpData.password" />
<div class="d-flex ga-2 align-center">
<div>
<v-btn @click="signUp">sign up</v-btn>
</div>
<v-spacer />
<router-link to="/verify-email" class="text-warning text-decoration-none"> Verify Email </router-link>
</div>
</div>
</v-col>
</v-row>
</template>
<style scoped lang="scss"></style>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { authClient } from '@/lib/auth-client.js'
import { reactive } from 'vue'
const verifyData = reactive({
email: 'vadim.olonin@gmail.com',
})
const verify = async () => {
const { data, error } = await authClient.sendVerificationEmail({
email: verifyData.email,
callbackURL: 'https://madsky.ru',
})
console.log(data)
console.log(error)
}
</script>
<template>
<v-row>
<v-col cols="4" offset="4">
<div class="d-flex flex-column ga-2">
<div class="text-uppercase">verify email</div>
<v-text-field placeholder="email" prepend-inner-icon="mdi-email" v-model="verifyData.email" />
<div>
<v-btn @click="verify">verify</v-btn>
</div>
</div>
</v-col>
</v-row>
</template>
<style scoped lang="scss"></style>