This commit is contained in:
alsaze 2025-11-17 18:40:04 +03:00
parent 98af218a35
commit 98cbe03fdf
30 changed files with 27121 additions and 0 deletions

1
.env.developer Normal file
View File

@ -0,0 +1 @@
VITE_MY_API_BASE_URL=https://jsonplaceholder.typicode.com

41
.gitignore vendored Normal file
View 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

View File

@ -0,0 +1,7 @@
import { httpClient } from '../httpClient'
export const getPosts = (search: string) =>
httpClient<Posts>('/api/posts', {
method: 'GET',
query: { search },
})

View File

@ -0,0 +1,4 @@
import { httpClient } from '../httpClient'
export const getUsers = () =>
httpClient<Users>('/api/users')

2
api/endpoints/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './getPosts'
export * from './getUsers'

21
api/httpClient.ts Normal file
View 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
View File

@ -0,0 +1,2 @@
export * from './useGetPosts'
export * from './useGetUsers'

View 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,
})
}

View 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
View 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
View File

@ -0,0 +1,2 @@
@import "tailwindcss";
@import "@nuxt/ui";

62
assets/scss/main.scss Normal file
View File

@ -0,0 +1,62 @@
@use 'utils' as *;
//скроллбар
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: rgba(128, 128, 128, 0.5);
border-radius: 4px;
border: 2px solid transparent;
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(128, 128, 128, 0.7);
background-clip: padding-box;
}
* {
scrollbar-width: thin;
scrollbar-color: rgba(128, 128, 128, 0.5) transparent;
}
body {
-ms-overflow-style: -ms-autohiding-scrollbar;
}
//swiper
.swiper {
width: 100%;
height: calc(100dvh - 54px);
}
.swiper-slide {
text-align: center;
font-size: 18px;
background: #444;
/* Center slide text vertically */
display: flex;
justify-content: center;
align-items: center;
}
.swiper-slide img {
display: block;
width: 100%;
height: calc(100dvh - 54px);
object-fit: cover;
}
.swiper-pagination {
position: absolute;
bottom: 200px !important;
--swiper-pagination-bullet-size: 4px
}

148
assets/scss/utils.scss Normal file
View File

@ -0,0 +1,148 @@
@use 'sass:color';
@mixin mobile {
@media (max-width: 768px) {
@content;
}
}
$indents: 0 2 4 5 6 8 10 12 15 16 18 20 24 25 28 30 32 36 40 48 50 52 60 64;
@each $i in $indents {
.m#{$i} {
margin: #{$i}px;
}
.mx#{$i} {
margin-left: #{$i}px;
margin-right: #{$i}px;
}
.my#{$i} {
margin-top: #{$i}px;
margin-bottom: #{$i}px;
}
.mt#{$i} {
margin-top: #{$i}px;
}
.mb#{$i} {
margin-bottom: #{$i}px;
}
.ml#{$i} {
margin-left: #{$i}px;
}
.mr#{$i} {
margin-right: #{$i}px;
}
.p#{$i} {
padding: #{$i}px;
}
.px#{$i} {
padding-left: #{$i}px;
padding-right: #{$i}px;
}
.py#{$i} {
padding-top: #{$i}px;
padding-bottom: #{$i}px;
}
.pt#{$i} {
padding-top: #{$i}px;
}
.pb#{$i} {
padding-bottom: #{$i}px;
}
.pl#{$i} {
padding-left: #{$i}px;
}
.pr#{$i} {
padding-right: #{$i}px;
}
}
.mla {
margin-left: auto;
}
.mra {
margin-left: auto;
}
.mx-auto {
margin-left: auto;
margin-right: auto;
}
@each $align in ('left', 'right', 'center') {
.text-align-#{$align} {
text-align: #{$align};
}
}
.w-25 { width: 25% !important; }
.w-50 { width: 50% !important; }
.w-75 { width: 75% !important; }
.w-100 { width: 100% !important; }
.d-none { display: none !important; }
.d-inline { display: inline !important; }
.d-inline-block { display: inline-block !important; }
.d-block { display: block !important; }
.d-table { display: table !important; }
.d-table-row { display: table-row !important; }
.d-table-cell { display: table-cell !important; }
.d-flex { display: flex !important; }
.d-inline-flex { display: inline-flex !important; }
.flex-row { flex-direction: row !important; }
.flex-column { flex-direction: column !important; }
.flex-row-reverse { flex-direction: row-reverse !important; }
.flex-column-reverse { flex-direction: column-reverse !important; }
.flex-wrap { flex-wrap: wrap !important; }
.flex-nowrap { flex-wrap: nowrap !important; }
.flex-wrap-reverse { flex-wrap: wrap-reverse !important; }
.justify-content-start { justify-content: flex-start !important; }
.justify-content-end { justify-content: flex-end !important; }
.justify-content-center { justify-content: center !important; }
.justify-content-between { justify-content: space-between !important; }
.justify-content-around { justify-content: space-around !important; }
.align-items-start { align-items: flex-start !important; }
.align-items-end { align-items: flex-end !important; }
.align-items-center { align-items: center !important; }
.align-items-baseline { align-items: baseline !important; }
.align-items-stretch { align-items: stretch !important; }
.align-content-start { align-content: flex-start !important; }
.align-content-end { align-content: flex-end !important; }
.align-content-center { align-content: center !important; }
.align-content-between { align-content: space-between !important; }
.align-content-around { align-content: space-around !important; }
.align-content-stretch { align-content: stretch !important; }
.align-self-auto { align-self: auto !important; }
.align-self-start { align-self: flex-start !important; }
.align-self-end { align-self: flex-end !important; }
.align-self-center { align-self: center !important; }
.align-self-baseline { align-self: baseline !important; }
.align-self-stretch { align-self: stretch !important; }
.text-align-center { text-align: center !important; }
.text-align-left { text-align: left !important; }
.text-align-right { text-align: right !important; }
.cursor-pointer { cursor: pointer !important; }
.cursor-not-allowed { cursor: not-allowed !important; }
.cursor-progress { cursor: progress !important; }

17
eslint.config.js Normal file
View 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
View File

@ -0,0 +1,3 @@
{
}

3
i18n/locales/ru.json Normal file
View File

@ -0,0 +1,3 @@
{
}

80
layouts/default.vue Normal file
View File

@ -0,0 +1,80 @@
<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>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
@use '~/assets/scss/utils' as *;
.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>

39
nuxt.config.ts Normal file
View File

@ -0,0 +1,39 @@
// https://nuxt.com/docs/api/configuration/nuxt-config
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',
},
],
},
},
})

16419
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
package.json Normal file
View File

@ -0,0 +1,44 @@
{
"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-table": "^8.21.3",
"@tanstack/vue-query-devtools": "^5.87.1",
"@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"
}

277
pages/index.vue Normal file
View File

@ -0,0 +1,277 @@
<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>
<div class="table-wrapper">
<table>
<thead>
<tr
v-for="headerGroup in table?.getHeaderGroups()"
:key="headerGroup?.id"
>
<th
v-for="header in headerGroup?.headers"
:key="header?.id"
:colSpan="header?.colSpan"
:style="header.column.columnDef.meta?.style"
@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>
<tr
v-for="row in table?.getRowModel()?.rows"
:key="row?.id"
>
<td
v-for="cell in row?.getVisibleCells()"
:key="cell?.id"
:style="cell.column.columnDef.meta?.style"
>
<FlexRender
:render="cell?.column?.columnDef?.cell"
:props="cell?.getContext()"
/>
</td>
</tr>
</tbody>
</table>
</div>
<UModal v-model:open="open" :title="selectedUser?.email">
<template #body>
<div>
<div>Имя: {{ selectedUser?.name }}</div>
<div>Логин: {{ selectedUser?.username }}</div>
<div>Электронная почта: {{ selectedUser?.email }}</div>
<div>Телефон: {{ selectedUser?.phone }}</div>
<a :href="`https://${selectedUser?.website}`" target="_blank">Веб-сайт: <span class="title-cell">{{ selectedUser?.website }}</span></a>
<div>Название компании: {{ selectedUser?.company?.name }}</div>
<div>Адрес: {{ `${selectedUser?.address?.street} ${selectedUser?.address?.suite} ${selectedUser?.address?.city}` }}</div>
</div>
</template>
</UModal>
</div>
</template>
<script setup lang="ts">
import { UTooltip } from '#components'
import {
createColumnHelper,
FlexRender,
getCoreRowModel,
getSortedRowModel,
useVueTable,
} from '@tanstack/vue-table'
import { useDebounceFn, useMediaQuery } from '@vueuse/core'
import { useGetPosts, useGetUsers } from '~/api/queries'
const isMobile = useMediaQuery('(max-width: 1280px)')
const open = ref(false)
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()
const tableData = computed(() =>
posts?.value?.map(post => ({
userId: post?.userId,
userEmail: users?.value?.find(user => user?.id === post?.userId)?.email,
id: post?.id,
title: post?.title,
body: post?.body,
})))
const columnHelper = createColumnHelper<Posts>()
const columns = [
columnHelper.accessor('id', {
header: 'ID',
footer: props => props?.column?.id,
meta: { style: 'text-align: center' },
}),
columnHelper.accessor('title', {
header: 'Заголовок',
cell: info => isMobile.value
? info.getValue()
: h(UTooltip, { text: info.getValue(),
}, {
default: () => h('span', { class: 'ellipsis' }, truncate(info.getValue()),
),
}),
}),
columnHelper.accessor('userEmail', {
header: 'Автор',
cell: info => h(
'span',
{ class: 'title-cell', onClick: () => openPost(info.row.original) },
info.getValue(),
),
meta: { style: 'text-align: center' },
}),
columnHelper.accessor('body', {
header: 'Контент',
cell: info => isMobile.value
? info.getValue()
: h(UTooltip, {
text: info.getValue(),
}, {
default: () => h('span', { class: 'ellipsis' }, truncate(info.getValue())),
}),
}),
]
const table = useVueTable({
get data() {
return tableData
},
columns,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
})
function openPost(post: Post) {
selectedUser.value = users?.value?.find(user => user?.id === post?.userId)
open.value = true
}
function truncate(text: string, max = 15) {
return text.length > max ? `${text.slice(0, max)}` : text
}
</script>
<style lang="scss" scoped>
@use '~/assets/scss/utils' as *;
.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;
}
}
}
/* -------------------- TABLE WRAPPER -------------------- */
.table-wrapper {
margin-top: calc(40px + 10px);
border-radius: 14px;
overflow: auto;
max-height: 600px;
scrollbar-width: thin;
}
table {
min-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 th {
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 {
background: var(--ui-bg-accented-hover);
}
/* -------------------- BODY -------------------- */
tbody tr {
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 {
padding: 12px 14px;
font-size: 14px;
border-bottom: 1px solid var(--ui-border-subtle);
}
tbody tr:last-child td {
border-bottom: none;
}
/* -------------------- SORT ICON -------------------- */
th {
position: relative;
}
th .sort-icon {
margin-left: 6px;
opacity: 0.7;
font-size: 12px;
}
/* -------------------- CLICKABLE USER EMAIL CELL -------------------- */
.title-cell {
cursor: pointer;
text-decoration: underline;
color: var(--ui-primary);
font-weight: 500;
}
.title-cell:hover {
color: var(--ui-primary-hover);
}
</style>

5
plugins/maska.ts Normal file
View File

@ -0,0 +1,5 @@
import { vMaska } from 'maska/vue'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.directive('maska', vMaska)
})

49
plugins/vue-query.ts Normal file
View 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
View File

@ -0,0 +1,21 @@
import { defineEventHandler, getQuery } from 'h3'
export default defineEventHandler(async (event) => {
try {
const { search } = getQuery(event)
const apiUrl = process.env.VITE_MY_API_BASE_URL!
return await $fetch(`${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
View File

@ -0,0 +1,20 @@
import { createError, defineEventHandler, sendError } from 'h3'
export default defineEventHandler(async (event) => {
try {
const apiUrl = process.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
View 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
View 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
View 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
View File

@ -0,0 +1,4 @@
{
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json",
}

9765
yarn.lock Normal file

File diff suppressed because it is too large Load Diff