This commit is contained in:
commit
3d975d6884
1
.env.developer
Normal file
1
.env.developer
Normal file
@ -0,0 +1 @@
|
|||||||
|
VITE_MY_API_BASE_URL=https://jsonplaceholder.typicode.com
|
||||||
40
.gitea/workflows/deploy.yml
Normal file
40
.gitea/workflows/deploy.yml
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Keyscan
|
||||||
|
run: |
|
||||||
|
ssh-keyscan git.koptilnya.xyz >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ssh-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
ssh-strict: false
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: docker build -t bambit:latest .
|
||||||
|
|
||||||
|
- name: Stop old container
|
||||||
|
run: docker rm -f bambit || true
|
||||||
|
|
||||||
|
- name: Run
|
||||||
|
run: |
|
||||||
|
docker run -d \
|
||||||
|
--name bambit \
|
||||||
|
--network traefik \
|
||||||
|
--label "traefik.enable=true" \
|
||||||
|
--label "traefik.http.routers.bambit.rule=Host(\`bambit.koptilnya.xyz\`)" \
|
||||||
|
--label "traefik.http.routers.bambit.entrypoints=websecure" \
|
||||||
|
--label "traefik.http.routers.bambit.tls.certresolver=myresolver" \
|
||||||
|
--label "traefik.http.services.bambit.loadbalancer.server.port=80" \
|
||||||
|
bambit:latest
|
||||||
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# nuxt
|
||||||
|
*.log*
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
.output
|
||||||
|
.env
|
||||||
|
dist
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# turbo
|
||||||
|
.turbo
|
||||||
|
out
|
||||||
|
|
||||||
|
.idea
|
||||||
|
.npmrc
|
||||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
RUN corepack enable
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN yarn install
|
||||||
|
COPY . .
|
||||||
|
RUN yarn build
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/.output/ ./
|
||||||
|
|
||||||
|
ENV PORT=80
|
||||||
|
|
||||||
|
ENV VITE_MY_API_BASE_URL=https://jsonplaceholder.typicode.com
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
CMD ["node", "/app/server/index.mjs"]
|
||||||
10
README.md
Normal file
10
README.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
node.js 20.19.0
|
||||||
|
|
||||||
|
yarn
|
||||||
|
|
||||||
|
yarn dev
|
||||||
|
|
||||||
|
<img width="846" height="1181" alt="image" src="https://github.com/user-attachments/assets/85f08dce-d4fc-4b7f-bbda-6dc3420df7b7" />
|
||||||
|
<img width="846" height="1157" alt="image" src="https://github.com/user-attachments/assets/cdbc5bac-7eec-4d23-9494-cc58253b84e0" />
|
||||||
|
<img width="864" height="1178" alt="image" src="https://github.com/user-attachments/assets/005ddcc0-a5d9-45d0-ac95-95f5e87b89f5" />
|
||||||
|
|
||||||
7
api/endpoints/getPosts.ts
Normal file
7
api/endpoints/getPosts.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { httpClient } from '../httpClient'
|
||||||
|
|
||||||
|
export const getPosts = (search: string) =>
|
||||||
|
httpClient<Posts>('/api/posts', {
|
||||||
|
method: 'GET',
|
||||||
|
query: { search },
|
||||||
|
})
|
||||||
4
api/endpoints/getUsers.ts
Normal file
4
api/endpoints/getUsers.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { httpClient } from '../httpClient'
|
||||||
|
|
||||||
|
export const getUsers = () =>
|
||||||
|
httpClient<Users>('/api/users')
|
||||||
2
api/endpoints/index.ts
Normal file
2
api/endpoints/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './getPosts'
|
||||||
|
export * from './getUsers'
|
||||||
21
api/httpClient.ts
Normal file
21
api/httpClient.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { useToast } from '#imports'
|
||||||
|
|
||||||
|
const showToast = (description: string) => {
|
||||||
|
if (!import.meta.client)
|
||||||
|
return
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
title: 'Uh oh! Something went wrong.',
|
||||||
|
description,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const httpClient = $fetch.create({
|
||||||
|
onResponseError({ request, response }) {
|
||||||
|
const message = response?._data?.statusMessage ?? 'Something went wrong'
|
||||||
|
|
||||||
|
showToast(message)
|
||||||
|
},
|
||||||
|
})
|
||||||
2
api/queries/index.ts
Normal file
2
api/queries/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './useGetPosts'
|
||||||
|
export * from './useGetUsers'
|
||||||
10
api/queries/useGetPosts.ts
Normal file
10
api/queries/useGetPosts.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
|
import { getPosts } from '~/api/endpoints'
|
||||||
|
|
||||||
|
export const useGetPosts = (search: MaybeRef<string>) => {
|
||||||
|
return useQuery<Posts>({
|
||||||
|
queryKey: ['get-posts', search],
|
||||||
|
queryFn: () => getPosts(unref(search)),
|
||||||
|
staleTime: Infinity,
|
||||||
|
})
|
||||||
|
}
|
||||||
10
api/queries/useGetUsers.ts
Normal file
10
api/queries/useGetUsers.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
|
import { getUsers } from '~/api/endpoints'
|
||||||
|
|
||||||
|
export const useGetUsers = () => {
|
||||||
|
return useQuery<Users>({
|
||||||
|
queryKey: ['get-users'],
|
||||||
|
queryFn: () => getUsers(),
|
||||||
|
staleTime: Infinity,
|
||||||
|
})
|
||||||
|
}
|
||||||
11
app.vue
Normal file
11
app.vue
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
<template>
|
||||||
|
<UApp>
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</UApp>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use '@/assets/scss/main' as *;
|
||||||
|
</style>
|
||||||
2
assets/css/main.css
Normal file
2
assets/css/main.css
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "@nuxt/ui";
|
||||||
8
assets/scss/main.scss
Normal file
8
assets/scss/main.scss
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba(128, 128, 128, 0.5) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
-ms-overflow-style: -ms-autohiding-scrollbar;
|
||||||
|
}
|
||||||
5
assets/scss/mixins.scss
Normal file
5
assets/scss/mixins.scss
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
@mixin mobile {
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
@content;
|
||||||
|
}
|
||||||
|
}
|
||||||
83
components/ShortText.vue
Normal file
83
components/ShortText.vue
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="wrapper" class="short-text">
|
||||||
|
<!-- Mobile -->
|
||||||
|
<UTooltip
|
||||||
|
v-if="isMobile"
|
||||||
|
:text="text"
|
||||||
|
:open="open"
|
||||||
|
:disabled="!tooltipEnabled"
|
||||||
|
:ui="{ text: 'whitespace-normal break-words', content: 'max-w-50 h-auto' }"
|
||||||
|
>
|
||||||
|
<span :ref="checkSize" :class="textPrimary ? 'short-text--primary' : ''">{{ text }}</span>
|
||||||
|
<Icon
|
||||||
|
v-if="tooltipEnabled"
|
||||||
|
name="heroicons:information-circle"
|
||||||
|
class="short-text__icon"
|
||||||
|
@click.stop="open = !open"
|
||||||
|
/>
|
||||||
|
</UTooltip>
|
||||||
|
|
||||||
|
<!-- Desktop -->
|
||||||
|
<UTooltip
|
||||||
|
v-else
|
||||||
|
:text="text"
|
||||||
|
:disabled="!tooltipEnabled"
|
||||||
|
:ui="{ text: 'whitespace-normal break-words', content: 'max-w-80 h-auto' }"
|
||||||
|
>
|
||||||
|
<span :ref="checkSize" :class="textPrimary ? 'short-text--primary' : ''">{{ text }}</span>
|
||||||
|
</UTooltip>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onClickOutside, unrefElement } from '@vueuse/core'
|
||||||
|
|
||||||
|
defineProps<{ text: string, textPrimary?: boolean }>()
|
||||||
|
|
||||||
|
const wrapper = ref<HTMLElement | null>(null)
|
||||||
|
const open = ref(false)
|
||||||
|
const tooltipEnabled = ref(true)
|
||||||
|
|
||||||
|
const { isMobile } = useScreen()
|
||||||
|
|
||||||
|
onClickOutside(wrapper, () => {
|
||||||
|
open.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
function checkSize(_el: HTMLElement) {
|
||||||
|
const el = unrefElement(_el)
|
||||||
|
|
||||||
|
tooltipEnabled.value = el ? el.scrollWidth > el.clientWidth : false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.short-text {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
&--primary {
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
color: var(--ui-primary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--ui-primary-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> span {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
214
components/UiTable.vue
Normal file
214
components/UiTable.vue
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="tableContainerRef"
|
||||||
|
class="table-wrapper"
|
||||||
|
:style="{ height, width }"
|
||||||
|
>
|
||||||
|
<div :style="{ height: `${totalSize}px` }">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr
|
||||||
|
v-for="headerGroup in table?.getHeaderGroups()"
|
||||||
|
:key="headerGroup?.id"
|
||||||
|
:style="{ gridTemplateColumns }"
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
v-for="header in headerGroup?.headers"
|
||||||
|
:key="header.id"
|
||||||
|
:colspan="header?.colSpan"
|
||||||
|
@click="header?.column?.getToggleSortingHandler()?.($event)"
|
||||||
|
>
|
||||||
|
<FlexRender
|
||||||
|
v-if="!header?.isPlaceholder"
|
||||||
|
:render="header?.column?.columnDef?.header"
|
||||||
|
:props="header?.getContext()"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{ { asc: ' 🔼', desc: ' 🔽' }[header.column.getIsSorted() as string] }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody :style="{ height: `${totalSize}px` }">
|
||||||
|
<tr
|
||||||
|
v-for="vRow in virtualRows"
|
||||||
|
:ref="measureElement"
|
||||||
|
:key="rows[vRow?.index]?.id"
|
||||||
|
:data-index=" vRow?.index"
|
||||||
|
:style="{ gridTemplateColumns, transform: `translateY(${vRow?.start}px)` }"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
v-for="cell in rows[vRow?.index]?.getVisibleCells()"
|
||||||
|
:key="cell?.id"
|
||||||
|
>
|
||||||
|
<FlexRender
|
||||||
|
:render="cell?.column?.columnDef?.cell"
|
||||||
|
:props="cell?.getContext()"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { RowData } from '@tanstack/table-core'
|
||||||
|
import type { ColumnDef } from '@tanstack/vue-table'
|
||||||
|
import { FlexRender, getCoreRowModel, getSortedRowModel, useVueTable } from '@tanstack/vue-table'
|
||||||
|
import { useVirtualizer } from '@tanstack/vue-virtual'
|
||||||
|
|
||||||
|
declare module '@tanstack/vue-table' {
|
||||||
|
interface ColumnMeta<TData extends RowData, TValue> {
|
||||||
|
width?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
tableData: any[]
|
||||||
|
columns: ColumnDef<any>[]
|
||||||
|
height: string
|
||||||
|
width: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const table = useVueTable({
|
||||||
|
get data() {
|
||||||
|
return props?.tableData
|
||||||
|
},
|
||||||
|
columns: props?.columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const rows = computed(() => table?.getRowModel()?.rows)
|
||||||
|
const tableContainerRef = ref<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
const rowVirtualizerOptions = computed(() => {
|
||||||
|
return {
|
||||||
|
count: rows?.value?.length,
|
||||||
|
estimateSize: () => 50,
|
||||||
|
getScrollElement: () => tableContainerRef?.value,
|
||||||
|
overscan: 5,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const rowVirtualizer = useVirtualizer(rowVirtualizerOptions)
|
||||||
|
|
||||||
|
const virtualRows = computed(() => rowVirtualizer?.value?.getVirtualItems())
|
||||||
|
const totalSize = computed(() => rowVirtualizer?.value?.getTotalSize())
|
||||||
|
|
||||||
|
const gridTemplateColumns = computed(() => {
|
||||||
|
return props.columns.map((column) => {
|
||||||
|
return column.meta?.width || '1fr'
|
||||||
|
}).join(' ')
|
||||||
|
})
|
||||||
|
|
||||||
|
function measureElement(el?: Element) {
|
||||||
|
if (!el) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rowVirtualizer?.value?.measureElement(el)
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
/* -------------------- TABLE WRAPPER -------------------- */
|
||||||
|
.table-wrapper {
|
||||||
|
position: relative;
|
||||||
|
margin-top: calc(40px + 10px);
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: auto;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
height: 500px !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 0;
|
||||||
|
|
||||||
|
background: var(--ui-bg-elevated);
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------- HEADER -------------------- */
|
||||||
|
|
||||||
|
thead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: var(--ui-bg-accented);
|
||||||
|
}
|
||||||
|
|
||||||
|
thead tr {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: var(--ui-bg-accented);
|
||||||
|
color: var(--ui-text);
|
||||||
|
padding: 12px 14px;
|
||||||
|
|
||||||
|
border-bottom: 1px solid var(--ui-border);
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------- BODY -------------------- */
|
||||||
|
|
||||||
|
tbody {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 50px;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:nth-child(odd) {
|
||||||
|
background: var(--ui-bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:hover {
|
||||||
|
background: var(--ui-bg-accented);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody td {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--ui-border-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
74
components/modals/UserModal.vue
Normal file
74
components/modals/UserModal.vue
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<template>
|
||||||
|
<UModal
|
||||||
|
v-model:open="open"
|
||||||
|
:title="user.email"
|
||||||
|
class="user-modal"
|
||||||
|
@after:leave="emit('close')"
|
||||||
|
>
|
||||||
|
<template #body>
|
||||||
|
<div>
|
||||||
|
<div>Имя: {{ user.name }}</div>
|
||||||
|
<div>Логин: {{ user.username }}</div>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
:href="`mailto:${user.email}`"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Электронная почта: <span>{{ user.email }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
:href="`tel:${user.phone}`"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Телефон: <span>{{ user.phone }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
:href="`https://${user.website}`"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Веб-сайт: <span>{{ user.website }}</span>
|
||||||
|
</a>
|
||||||
|
<div>Название компании: {{ user.company.name }}</div>
|
||||||
|
<div>Адрес: {{ `${user.address.street} ${user.address.suite} ${user.address.city}` }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{ user: User }>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const open = ref(!!props.user)
|
||||||
|
|
||||||
|
watch(() => props.user, (user) => {
|
||||||
|
if (!user)
|
||||||
|
return
|
||||||
|
|
||||||
|
open.value = true
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.user-modal {
|
||||||
|
a {
|
||||||
|
span {
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
color: var(--ui-primary);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--ui-primary-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
12
composables/useScreen.ts
Normal file
12
composables/useScreen.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { useMediaQuery } from '@vueuse/core'
|
||||||
|
|
||||||
|
export default function useScreen() {
|
||||||
|
const isMobile = useMediaQuery('(max-width: 1279px)')
|
||||||
|
|
||||||
|
const isDesktop = computed(() => !isMobile.value)
|
||||||
|
|
||||||
|
return {
|
||||||
|
isMobile,
|
||||||
|
isDesktop,
|
||||||
|
}
|
||||||
|
}
|
||||||
17
eslint.config.js
Normal file
17
eslint.config.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import antfu from '@antfu/eslint-config'
|
||||||
|
|
||||||
|
export default antfu({
|
||||||
|
rules: {
|
||||||
|
'antfu/top-level-function': 'off',
|
||||||
|
},
|
||||||
|
formatters: {
|
||||||
|
css: true,
|
||||||
|
},
|
||||||
|
overrides: {
|
||||||
|
vue: {
|
||||||
|
'vue/block-order': ['error', {
|
||||||
|
order: ['template', 'script', 'style'],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
3
i18n/locales/en.json
Normal file
3
i18n/locales/en.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
3
i18n/locales/ru.json
Normal file
3
i18n/locales/ru.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
75
layouts/default.vue
Normal file
75
layouts/default.vue
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div class="layout">
|
||||||
|
<header class="header bg-default">
|
||||||
|
<div class="header__container">
|
||||||
|
<h1>BambitTestTask</h1>
|
||||||
|
|
||||||
|
<UColorModeSwitch />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<UContainer class="container">
|
||||||
|
<slot />
|
||||||
|
</UContainer>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.layout {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
height: 48px;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&__container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin-inline: auto;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
padding-inline: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__container h1 {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__container > *:last-child {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
--ui-container: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
margin-top: calc(32px + 20px);
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
padding-inline: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
55
nuxt.config.ts
Normal file
55
nuxt.config.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
import { createResolver } from '@nuxt/kit'
|
||||||
|
|
||||||
|
const { resolve } = createResolver(import.meta.url)
|
||||||
|
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
ssr: true,
|
||||||
|
compatibilityDate: '2025-05-15',
|
||||||
|
devtools: { enabled: true },
|
||||||
|
modules: [
|
||||||
|
'@nuxt/ui',
|
||||||
|
'@nuxt/image',
|
||||||
|
'@nuxt/icon',
|
||||||
|
'@nuxt/fonts',
|
||||||
|
'@nuxtjs/i18n',
|
||||||
|
[
|
||||||
|
'@vee-validate/nuxt',
|
||||||
|
{
|
||||||
|
autoImports: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
],
|
||||||
|
css: ['~/assets/css/main.css', '~/assets/scss/main.scss'],
|
||||||
|
i18n: {
|
||||||
|
locales: [
|
||||||
|
{ code: 'en', name: 'English', file: 'en.json' },
|
||||||
|
{ code: 'ru', name: 'Русский', file: 'ru.json' },
|
||||||
|
],
|
||||||
|
defaultLocale: 'ru',
|
||||||
|
strategy: 'prefix_except_default',
|
||||||
|
detectBrowserLanguage: false,
|
||||||
|
},
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
name: 'viewport',
|
||||||
|
content: 'width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, user-scalable=no',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vite: {
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
additionalData: `@use "${resolve('./assets/scss/mixins')}" as *;`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fonts: {
|
||||||
|
provider: 'google',
|
||||||
|
},
|
||||||
|
})
|
||||||
16419
package-lock.json
generated
Normal file
16419
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
package.json
Normal file
45
package.json
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "nuxt-app",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"lint:fix": "eslint . --fix",
|
||||||
|
"typegen": "node .typegen"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxt/content": "^3.7.1",
|
||||||
|
"@nuxt/fonts": "0.11.4",
|
||||||
|
"@nuxt/icon": "1.15.0",
|
||||||
|
"@nuxt/image": "1.10.0",
|
||||||
|
"@nuxt/ui": "^4.0.1",
|
||||||
|
"@nuxtjs/i18n": "^10.0.4",
|
||||||
|
"@tanstack/vue-query": "^5.75.5",
|
||||||
|
"@tanstack/vue-query-devtools": "^5.87.1",
|
||||||
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
|
"@tanstack/vue-virtual": "^3.13.12",
|
||||||
|
"@vee-validate/nuxt": "^4.15.1",
|
||||||
|
"@vueuse/core": "^13.1.0",
|
||||||
|
"axios": "^1.12.2",
|
||||||
|
"dayjs": "^1.11.13",
|
||||||
|
"decimal.js": "^10.5.0",
|
||||||
|
"maska": "^3.2.0",
|
||||||
|
"nuxt": "^4.1.3",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vue": "^3.5.17",
|
||||||
|
"vue-router": "^4.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@antfu/eslint-config": "^4.13.2",
|
||||||
|
"@nuxt/devtools": "latest",
|
||||||
|
"eslint": "^9.27.0",
|
||||||
|
"eslint-plugin-format": "^1.0.1",
|
||||||
|
"sass": "^1.71.0"
|
||||||
|
},
|
||||||
|
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||||
|
}
|
||||||
123
pages/index.vue
Normal file
123
pages/index.vue
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
<template>
|
||||||
|
<div class="index-page">
|
||||||
|
<div class="index-page__search">
|
||||||
|
<UInput
|
||||||
|
:model-value="search"
|
||||||
|
size="xl"
|
||||||
|
class="w-full"
|
||||||
|
placeholder="Поиск..."
|
||||||
|
:loading="postsIsLoading || usersIsLoading"
|
||||||
|
@update:model-value="updateSearch"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UiTable :table-data="tableData" :columns="columns" height="600px" width="600px" />
|
||||||
|
|
||||||
|
<UserModal v-if="selectedUser" :user="selectedUser" @close="selectedUser = null" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ColumnDef } from '@tanstack/table-core'
|
||||||
|
import { createColumnHelper } from '@tanstack/vue-table'
|
||||||
|
import { useDebounceFn } from '@vueuse/core'
|
||||||
|
import { useGetPosts, useGetUsers } from '~/api/queries'
|
||||||
|
import UserModal from '~/components/modals/UserModal.vue'
|
||||||
|
import ShortText from '~/components/ShortText.vue'
|
||||||
|
import UiTable from '~/components/UiTable.vue'
|
||||||
|
|
||||||
|
const selectedUser = ref<User | null>(null)
|
||||||
|
const search = ref('')
|
||||||
|
const updateSearch = useDebounceFn((value: string) => {
|
||||||
|
search.value = value
|
||||||
|
}, 500)
|
||||||
|
const { data: posts, isLoading: postsIsLoading } = useGetPosts(search)
|
||||||
|
const { data: users, isLoading: usersIsLoading } = useGetUsers()
|
||||||
|
|
||||||
|
interface TablePost extends Post {
|
||||||
|
userEmail: User['email']
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableData = computed(() => {
|
||||||
|
return (posts.value ?? []).map(post => ({
|
||||||
|
...post,
|
||||||
|
userEmail: users?.value?.find(user => user?.id === post?.userId)?.email,
|
||||||
|
} as TablePost))
|
||||||
|
})
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<TablePost>()
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor('id', {
|
||||||
|
header: 'ID',
|
||||||
|
meta: {
|
||||||
|
width: '50px',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('title', {
|
||||||
|
header: 'Заголовок',
|
||||||
|
cell: info => h(ShortText, {
|
||||||
|
text: info.getValue(),
|
||||||
|
}),
|
||||||
|
meta: {
|
||||||
|
width: '150px',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('userEmail', {
|
||||||
|
header: 'Автор',
|
||||||
|
cell: info =>
|
||||||
|
h(ShortText, {
|
||||||
|
text: info.getValue(),
|
||||||
|
textPrimary: true,
|
||||||
|
onClick: () => openPost(info.row.original),
|
||||||
|
}),
|
||||||
|
meta: {
|
||||||
|
width: '170px',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
columnHelper.accessor('body', {
|
||||||
|
header: 'Контент',
|
||||||
|
cell: info => h(ShortText, {
|
||||||
|
text: info.getValue(),
|
||||||
|
}),
|
||||||
|
meta: {
|
||||||
|
width: 'minmax(220px, 1fr)',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
] as ColumnDef<TablePost>[]
|
||||||
|
|
||||||
|
function openPost(post: Post) {
|
||||||
|
const foundUser = users.value?.find(user => user?.id === post.userId)
|
||||||
|
|
||||||
|
if (foundUser) {
|
||||||
|
selectedUser.value = foundUser
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.index-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
max-width: 600px;
|
||||||
|
margin-inline: auto;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&__search {
|
||||||
|
position: fixed;
|
||||||
|
top: 48px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-width: 600px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
z-index: 1000;
|
||||||
|
background: var(--ui-bg-base);
|
||||||
|
|
||||||
|
@include mobile {
|
||||||
|
padding-inline: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
49
plugins/vue-query.ts
Normal file
49
plugins/vue-query.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import type {
|
||||||
|
DehydratedState,
|
||||||
|
VueQueryPluginOptions,
|
||||||
|
} from '@tanstack/vue-query'
|
||||||
|
import { defineNuxtPlugin, useState } from '#imports'
|
||||||
|
import {
|
||||||
|
dehydrate,
|
||||||
|
hydrate,
|
||||||
|
QueryClient,
|
||||||
|
VueQueryPlugin,
|
||||||
|
} from '@tanstack/vue-query'
|
||||||
|
|
||||||
|
import { VueQueryDevtools } from '@tanstack/vue-query-devtools'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxt) => {
|
||||||
|
const vueQueryState = useState<DehydratedState | null>('vue-query')
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
},
|
||||||
|
mutations: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const options: VueQueryPluginOptions = { queryClient }
|
||||||
|
|
||||||
|
nuxt.vueApp.use(VueQueryPlugin, options)
|
||||||
|
|
||||||
|
if (import.meta.server) {
|
||||||
|
nuxt.hooks.hook('app:rendered', () => {
|
||||||
|
vueQueryState.value = dehydrate(queryClient)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.client) {
|
||||||
|
nuxt.hooks.hook('app:created', () => {
|
||||||
|
hydrate(queryClient, vueQueryState.value)
|
||||||
|
|
||||||
|
nuxt.vueApp.use(VueQueryDevtools, {
|
||||||
|
initialIsOpen: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
21
server/api/posts.ts
Normal file
21
server/api/posts.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { defineEventHandler, getQuery } from 'h3'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const { search } = getQuery(event)
|
||||||
|
const apiUrl = import.meta.env.VITE_MY_API_BASE_URL!
|
||||||
|
|
||||||
|
return await $fetch<Posts>(`${apiUrl}/posts?title_like=${search}`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept-Language': 'ru-RU',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
return sendError(event, createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: `Не удалось получить посты: ${error}`,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
20
server/api/users.ts
Normal file
20
server/api/users.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { createError, defineEventHandler, sendError } from 'h3'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
try {
|
||||||
|
const apiUrl = import.meta.env.VITE_MY_API_BASE_URL!
|
||||||
|
|
||||||
|
return await $fetch<Users>(`${apiUrl}/users`, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept-Language': 'ru-RU',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
return sendError(event, createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: `Не удалось получить пользователей: ${error}`,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
})
|
||||||
7
shared/types/posts.ts
Normal file
7
shared/types/posts.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export interface Post {
|
||||||
|
userId: number
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
}
|
||||||
|
export type Posts = Post[]
|
||||||
31
shared/types/users.ts
Normal file
31
shared/types/users.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
export interface UserCompany {
|
||||||
|
name: string
|
||||||
|
catchPhrase: string
|
||||||
|
bs: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserAddressGeo {
|
||||||
|
lat: string
|
||||||
|
lng: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserAddress {
|
||||||
|
street: string
|
||||||
|
suite: string
|
||||||
|
city: string
|
||||||
|
zipcode: string
|
||||||
|
geo: UserAddressGeo
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
username: string
|
||||||
|
email: string
|
||||||
|
address: UserAddress
|
||||||
|
phone: string
|
||||||
|
website: string
|
||||||
|
company: UserCompany
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Users = User[]
|
||||||
16
tailwind.config.ts
Normal file
16
tailwind.config.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// tailwind.config.ts
|
||||||
|
import type { Config } from 'tailwindcss'
|
||||||
|
|
||||||
|
const config: Config = {
|
||||||
|
content: [], // Nuxt сам заполняет content автоматически
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'sans-serif'], // если используешь @nuxt/fonts или свою кастомную типографику
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
4
tsconfig.json
Normal file
4
tsconfig.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"extends": "./.nuxt/tsconfig.json",
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user