init
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
NUXT_PUBLIC_ALIAS=
|
||||||
|
NUXT_PUBLIC_API_URL="https://some.ru/${NUXT_PUBLIC_ALIAS}"
|
||||||
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
.scripts
|
||||||
|
justfile
|
||||||
75
README.md
Normal file
75
README.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Nuxt Minimal Starter
|
||||||
|
|
||||||
|
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Make sure to install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Server
|
||||||
|
|
||||||
|
Start the development server on `http://localhost:3000`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn dev
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
Build the application for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Locally preview production build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run preview
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm preview
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn preview
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||||
19
app/app.vue
Normal file
19
app/app.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type {Editor} from "@tiptap/vue-3";
|
||||||
|
import {EDITOR_KEY} from "~/shared/constants";
|
||||||
|
|
||||||
|
const editor = ref<Editor | null>(null)
|
||||||
|
|
||||||
|
|
||||||
|
provide(EDITOR_KEY, editor)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use 'assets/styles/index';
|
||||||
|
</style>
|
||||||
BIN
app/assets/fonts/futuraPT/FuturaPT-Book.ttf
Normal file
BIN
app/assets/fonts/futuraPT/FuturaPT-Book.ttf
Normal file
Binary file not shown.
BIN
app/assets/fonts/futuraPT/FuturaPT-Medium.ttf
Normal file
BIN
app/assets/fonts/futuraPT/FuturaPT-Medium.ttf
Normal file
Binary file not shown.
BIN
app/assets/fonts/sourceSans/SourceSans3-SemiBold.ttf
Normal file
BIN
app/assets/fonts/sourceSans/SourceSans3-SemiBold.ttf
Normal file
Binary file not shown.
2
app/assets/styles/fonts.scss
Normal file
2
app/assets/styles/fonts.scss
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@use "fonts/futuraPT";
|
||||||
|
@use "fonts/sourceSans";
|
||||||
13
app/assets/styles/fonts/futuraPT.scss
Normal file
13
app/assets/styles/fonts/futuraPT.scss
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'FuturaPT';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
src: url('@/assets/fonts/futuraPT/FuturaPT-Book.ttf') format('truetype');
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'FuturaPT';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
src: url('@/assets/fonts/futuraPT/FuturaPT-Medium.ttf') format('truetype');
|
||||||
|
}
|
||||||
6
app/assets/styles/fonts/sourceSans.scss
Normal file
6
app/assets/styles/fonts/sourceSans.scss
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: 'SourceSans3';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
src: url('@/assets/fonts/sourceSans/SourceSans3-SemiBold.ttf') format('truetype');
|
||||||
|
}
|
||||||
19
app/assets/styles/index.scss
Normal file
19
app/assets/styles/index.scss
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
@use "fonts";
|
||||||
|
@use "mixins";
|
||||||
|
@use "vars";
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
button {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,h2,h3,h4,h5,h6 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
6
app/assets/styles/mixins.scss
Normal file
6
app/assets/styles/mixins.scss
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
@mixin square($size: 10px) {
|
||||||
|
width: $size;
|
||||||
|
min-width: $size;
|
||||||
|
height: $size;
|
||||||
|
min-height: $size;
|
||||||
|
}
|
||||||
199
app/assets/styles/tiptap.scss
Normal file
199
app/assets/styles/tiptap.scss
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
/* Основные стили для контейнера редактора */
|
||||||
|
.ProseMirror {
|
||||||
|
min-height: 260px;
|
||||||
|
padding: 48px 24px;
|
||||||
|
border: 1px solid #d0d7de;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
color: #24292f;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color .15s;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Состояния фокуса */
|
||||||
|
.ProseMirror-focused {
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 0 0 2px #e0e7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стилизация параграфа и базового текста */
|
||||||
|
.ProseMirror p {
|
||||||
|
margin: 1em 0;
|
||||||
|
}
|
||||||
|
.ProseMirror strong { font-weight: 700; }
|
||||||
|
.ProseMirror em { font-style: italic; }
|
||||||
|
.ProseMirror u { text-decoration: underline; }
|
||||||
|
.ProseMirror s { text-decoration: line-through; }
|
||||||
|
.ProseMirror code {
|
||||||
|
background: #f6f8fa;
|
||||||
|
color: #d63384;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.95em;
|
||||||
|
font-family: 'JetBrainsMono', 'Fira Mono', 'Menlo', monospace;
|
||||||
|
padding: 0.1em 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Заголовки */
|
||||||
|
.ProseMirror h1,
|
||||||
|
.ProseMirror h2,
|
||||||
|
.ProseMirror h3,
|
||||||
|
.ProseMirror h4,
|
||||||
|
.ProseMirror h5,
|
||||||
|
.ProseMirror h6 {
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 1.6em 0 0.8em;
|
||||||
|
}
|
||||||
|
.ProseMirror h1 { font-size: 2em; }
|
||||||
|
.ProseMirror h2 { font-size: 1.6em; }
|
||||||
|
.ProseMirror h3 { font-size: 1.34em;}
|
||||||
|
.ProseMirror h4 { font-size: 1.18em;}
|
||||||
|
.ProseMirror h5 { font-size: 1em; }
|
||||||
|
.ProseMirror h6 { font-size: 1em; color: #555; }
|
||||||
|
|
||||||
|
/* Списки */
|
||||||
|
.ProseMirror ul,
|
||||||
|
.ProseMirror ol {
|
||||||
|
margin: 0.8em 0 0.8em 2em;
|
||||||
|
padding: 0 0 0 1.2em;
|
||||||
|
}
|
||||||
|
.ProseMirror li {
|
||||||
|
margin: 0.2em 0;
|
||||||
|
padding-left: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Quote and horizontal rule */
|
||||||
|
.ProseMirror blockquote {
|
||||||
|
color: #6b7280;
|
||||||
|
margin: 1.2em 0;
|
||||||
|
padding-left: 0;
|
||||||
|
font-style: italic;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Табы и преформат */
|
||||||
|
.ProseMirror pre {
|
||||||
|
background: #282a36;
|
||||||
|
color: #f6f8fa;
|
||||||
|
padding: 1em;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: 'JetBrainsMono', 'Fira Mono', 'Menlo', monospace;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.ProseMirror pre code {
|
||||||
|
background: unset;
|
||||||
|
color: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HR */
|
||||||
|
.ProseMirror hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
margin: 2em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Изображения */
|
||||||
|
.ProseMirror img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ссылки */
|
||||||
|
.ProseMirror a {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: underline;
|
||||||
|
transition: color .15s;
|
||||||
|
}
|
||||||
|
.ProseMirror a:hover {
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Сброс плэйсхолдера и подсветка выделения */
|
||||||
|
.ProseMirror-gapcursor {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.ProseMirror-selectednode {
|
||||||
|
outline: 2px solid #6366f1;
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для невалидных узлов (например, плейсхолдеров или ошибок) */
|
||||||
|
.ProseMirror .is-invalid {
|
||||||
|
background: #ffe4e6;
|
||||||
|
color: #be185d;
|
||||||
|
padding: 0.1em 0.3em;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror p.is-editor-empty[data-placeholder]:before,
|
||||||
|
.ProseMirror h1.is-editor-empty[data-placeholder]:before,
|
||||||
|
.ProseMirror h2.is-editor-empty[data-placeholder]:before,
|
||||||
|
.ProseMirror h3.is-editor-empty[data-placeholder]:before{
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
color: #bdbdbd;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
padding-left: 106px;
|
||||||
|
font-style: normal;
|
||||||
|
|
||||||
|
font-family: SourceSans3, monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 36px;
|
||||||
|
line-height: 120%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror p.is-editor-empty[data-placeholder]:before {
|
||||||
|
font-family: FuturaPT, sans-serif;
|
||||||
|
}
|
||||||
|
.ProseMirror p:nth-of-type(1).is-editor-empty[data-placeholder]:before {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.ProseMirror p.is-editor-empty[data-placeholder]:before {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ProseMirror {
|
||||||
|
h1 {
|
||||||
|
font-family: SourceSans3, monospace;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
p:first-of-type {
|
||||||
|
font-family: FuturaPT, monospace;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 120%;
|
||||||
|
}
|
||||||
|
p:not(:first-of-type) {
|
||||||
|
font-family: FuturaPT, monospace;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 120%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
|
||||||
|
.ProseMirror p.is-editor-empty[data-placeholder]:before,
|
||||||
|
.ProseMirror h1.is-editor-empty[data-placeholder]:before,
|
||||||
|
.ProseMirror h2.is-editor-empty[data-placeholder]:before,
|
||||||
|
.ProseMirror h3.is-editor-empty[data-placeholder]:before{
|
||||||
|
padding-left: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.ProseMirror p:nth-of-type(1).is-editor-empty[data-placeholder]:before {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.ProseMirror p.is-editor-empty[data-placeholder]:before {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
7
app/assets/styles/vars.scss
Normal file
7
app/assets/styles/vars.scss
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
:root {
|
||||||
|
--text-blue: #00B2FF;
|
||||||
|
--text-black: #14142A;
|
||||||
|
--text-gray: #81859C;
|
||||||
|
--text-light-gray: #BEC2DA;
|
||||||
|
|
||||||
|
}
|
||||||
101
app/components/MaterialsItem.vue
Normal file
101
app/components/MaterialsItem.vue
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<script lang="ts" setup="">
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import ru from 'dayjs/locale/ru'
|
||||||
|
import type {Material} from "~/stores/materials/types.materials";
|
||||||
|
import IconCalendar from "~/components/icons/iconCalendar.vue";
|
||||||
|
|
||||||
|
dayjs.locale(ru);
|
||||||
|
interface Props {
|
||||||
|
item: Material
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const date = computed(() => dayjs(props.item.datetime).format('D MMMM'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="item-container">
|
||||||
|
<div class="date-container">
|
||||||
|
<IconCalendar class="icon" />
|
||||||
|
<span class="date">{{date}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-content" >
|
||||||
|
<h2 class="title">{{item.title}}</h2>
|
||||||
|
<p class="description">{{item.short_description}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.item-container {
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
transition: all .3s ease;
|
||||||
|
box-shadow: unset;
|
||||||
|
background: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
@include mixins.square(276px);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: -2px 5px 13px 0px #0000000A, -9px 21px 23px 0px #0000000A, -21px 48px 31px 0px #00000005, -37px 85px 37px 0px #00000003, -57px 133px 40px 0px #00000000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-light-gray);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
@include mixins.square(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
font-family: FuturaPT, monospace;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-content {
|
||||||
|
.title, .description {
|
||||||
|
padding-top: 20px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: SourceSans3, monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 120%;
|
||||||
|
color: var(--text-black)
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-family: FuturaPT, monospace;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 120%;
|
||||||
|
color: var(--text-gray);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.item-container {
|
||||||
|
max-width: unset;
|
||||||
|
min-width: unset;
|
||||||
|
width: unset;
|
||||||
|
height: unset;
|
||||||
|
min-height: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
364
app/components/TiptapEditor.vue
Normal file
364
app/components/TiptapEditor.vue
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="editor" class="container tiptap">
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="button-group">
|
||||||
|
<button
|
||||||
|
:class="{ 'is-active': editor.isActive('paragraph') }"
|
||||||
|
@click="editor.chain().focus().setParagraph().run()"
|
||||||
|
>
|
||||||
|
Paragraph
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:disabled="hasH1"
|
||||||
|
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
|
||||||
|
@click="toggleH1"
|
||||||
|
>
|
||||||
|
H1
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
|
||||||
|
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
|
||||||
|
>
|
||||||
|
H2
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="{ 'is-active': editor.isActive('heading', { level: 3 }) }"
|
||||||
|
@click="editor.chain().focus().toggleHeading({ level: 3 }).run()"
|
||||||
|
>
|
||||||
|
H3
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
:class="{ 'is-active': editor.isActive('blockquote') }"
|
||||||
|
@click="editor.chain().focus().toggleBlockquote().run()"
|
||||||
|
>
|
||||||
|
Blockquote
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<EditorContent :editor="editor" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, computed, inject, ref, Ref } from 'vue'
|
||||||
|
import { Editor, EditorContent } from '@tiptap/vue-3'
|
||||||
|
import StarterKit from '@tiptap/starter-kit'
|
||||||
|
import { ListItem } from '@tiptap/extension-list'
|
||||||
|
import { Placeholder } from '@tiptap/extensions'
|
||||||
|
import { Color, TextStyle } from '@tiptap/extension-text-style'
|
||||||
|
import { EDITOR_KEY } from '~/shared/constants'
|
||||||
|
|
||||||
|
const TITLE_MAX = 80
|
||||||
|
const DESCRIPTION_MAX = 255
|
||||||
|
|
||||||
|
const editor = inject<Ref<Editor | null>>(EDITOR_KEY)
|
||||||
|
|
||||||
|
const titleLength = ref(0)
|
||||||
|
const descriptionLength = ref(0)
|
||||||
|
|
||||||
|
const hasH1 = computed(() => {
|
||||||
|
if (!editor?.value) return false
|
||||||
|
let found = false
|
||||||
|
editor.value.state.doc.descendants((node) => {
|
||||||
|
if (node.type.name === 'heading' && node.attrs.level === 1) {
|
||||||
|
found = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return found
|
||||||
|
})
|
||||||
|
|
||||||
|
// Получить текст H1
|
||||||
|
function getH1Text(): string {
|
||||||
|
if (!editor?.value) return ''
|
||||||
|
let h1Text = ''
|
||||||
|
editor.value.state.doc.descendants((node) => {
|
||||||
|
if (node.type.name === 'heading' && node.attrs.level === 1) {
|
||||||
|
h1Text = node.textContent
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return h1Text
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить текст первого P
|
||||||
|
function getFirstPText(): string {
|
||||||
|
if (!editor?.value) return ''
|
||||||
|
let firstPText = ''
|
||||||
|
let foundP = false
|
||||||
|
editor.value.state.doc.descendants((node) => {
|
||||||
|
if (node.type.name === 'paragraph' && !foundP) {
|
||||||
|
firstPText = node.textContent
|
||||||
|
foundP = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return firstPText
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновить счетчики
|
||||||
|
function updateCharCount() {
|
||||||
|
titleLength.value = getH1Text().length
|
||||||
|
descriptionLength.value = getFirstPText().length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для переключения H1
|
||||||
|
function toggleH1() {
|
||||||
|
if (!editor?.value) return
|
||||||
|
|
||||||
|
if (!hasH1.value) {
|
||||||
|
editor.value.chain().focus().toggleHeading({ level: 1 }).run()
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить валидные данные
|
||||||
|
function getValidatedContent() {
|
||||||
|
const titleText = getH1Text()
|
||||||
|
const descriptionText = getFirstPText()
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: titleText,
|
||||||
|
short_description: descriptionText,
|
||||||
|
content: editor?.value?.getJSON()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
getValidatedContent
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!editor) return
|
||||||
|
|
||||||
|
editor.value = new Editor({
|
||||||
|
extensions: [
|
||||||
|
Color.configure({ types: [TextStyle.name, ListItem.name] }),
|
||||||
|
TextStyle.configure({ types: [ListItem.name] }),
|
||||||
|
StarterKit,
|
||||||
|
Placeholder.configure({
|
||||||
|
emptyNodeClass: 'my-custom-is-empty-class',
|
||||||
|
showOnlyWhenEditable: false,
|
||||||
|
showOnlyCurrent: false,
|
||||||
|
placeholder: ({ node, editor, pos }) => {
|
||||||
|
if (node.type.name === 'heading') {
|
||||||
|
return 'Введите заголовок'
|
||||||
|
}
|
||||||
|
if (node.type.name === 'paragraph') {
|
||||||
|
let paragraphIndex = 0
|
||||||
|
let foundPos = null
|
||||||
|
|
||||||
|
editor.state.doc.descendants((child, childPos) => {
|
||||||
|
if (child.type.name === 'paragraph') {
|
||||||
|
if (childPos === pos) {
|
||||||
|
foundPos = paragraphIndex
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
paragraphIndex++
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
if (foundPos === 0) {
|
||||||
|
return 'Введите краткое описание...'
|
||||||
|
}
|
||||||
|
return 'Введите текст...'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
content: `
|
||||||
|
<h1></h1>
|
||||||
|
<p></p>
|
||||||
|
<p></p>
|
||||||
|
`,
|
||||||
|
onUpdate: ({ editor }) => {
|
||||||
|
updateCharCount()
|
||||||
|
|
||||||
|
let paragraphIndex = 0
|
||||||
|
let h1Found = false
|
||||||
|
|
||||||
|
editor.state.doc.descendants((node, pos) => {
|
||||||
|
// Ограничиваем H1
|
||||||
|
if (node.type.name === 'heading' && node.attrs.level === 1) {
|
||||||
|
const text = node.textContent
|
||||||
|
if (text.length > TITLE_MAX) {
|
||||||
|
editor.commands.setTextSelection({
|
||||||
|
from: pos + TITLE_MAX + 1,
|
||||||
|
to: pos + text.length + 1
|
||||||
|
})
|
||||||
|
editor.commands.deleteSelection()
|
||||||
|
}
|
||||||
|
h1Found = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ограничиваем только первый P
|
||||||
|
if (node.type.name === 'paragraph') {
|
||||||
|
if (paragraphIndex === 0) {
|
||||||
|
const text = node.textContent
|
||||||
|
if (text.length > DESCRIPTION_MAX) {
|
||||||
|
editor.commands.setTextSelection({
|
||||||
|
from: pos + DESCRIPTION_MAX + 1,
|
||||||
|
to: pos + text.length + 1
|
||||||
|
})
|
||||||
|
editor.commands.deleteSelection()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paragraphIndex++
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
updateCharCount()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (!editor?.value) return
|
||||||
|
editor.value?.destroy()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.tiptap {
|
||||||
|
:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
border-radius: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul,
|
||||||
|
ol {
|
||||||
|
padding: 0 1rem;
|
||||||
|
margin: 1.25rem 1rem 1.25rem 0.4rem;
|
||||||
|
|
||||||
|
li p {
|
||||||
|
margin-top: 0.25em;
|
||||||
|
margin-bottom: 0.25em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
line-height: 1.1;
|
||||||
|
margin-top: 2.5rem;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2 {
|
||||||
|
margin-top: 3.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-family: SourceSans3, monospace;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
code {
|
||||||
|
background-color: var(--purple-light);
|
||||||
|
border-radius: 0.4rem;
|
||||||
|
color: var(--black);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.25em 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--black);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
color: var(--white);
|
||||||
|
font-family: 'JetBrainsMono', monospace;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: none;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 3px solid var(--gray-3);
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--gray-2);
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tiptap p.is-empty::before {
|
||||||
|
color: #adb5bd;
|
||||||
|
content: attr(data-placeholder);
|
||||||
|
float: left;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid var(--gray-3, #ddd);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background-color: var(--gray-1, #f5f5f5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background-color: var(--blue-light, #e3f2fd);
|
||||||
|
border-color: var(--blue, #1976d2);
|
||||||
|
color: var(--blue, #1976d2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
117
app/components/baseHeader.vue
Normal file
117
app/components/baseHeader.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<script lang="ts" setup="">
|
||||||
|
|
||||||
|
import IconLogo from "~/components/icons/iconLogo.vue";
|
||||||
|
import {Button} from "~/components/ui/Buttons";
|
||||||
|
import {ButtonKind} from "~/components/ui/Buttons/constants.buttons";
|
||||||
|
import {EDITOR_KEY} from "~/shared/constants";
|
||||||
|
import type {Editor} from "@tiptap/vue-3";
|
||||||
|
import type {MaterialSendDto} from "~/stores/materials/types.materials";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import {useMaterialsStore} from "~/stores/materials/useMaterialsStore";
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const editor = inject<Ref<Editor | null>>(EDITOR_KEY)
|
||||||
|
|
||||||
|
const isLoadingSave = ref(false);
|
||||||
|
const materialsStore = useMaterialsStore()
|
||||||
|
|
||||||
|
const isntMaterialCreatePage = computed(() => route.name !== 'materials-create')
|
||||||
|
const getButtonKind = computed(() => {
|
||||||
|
if (route.name === 'materials-create') return ButtonKind.Send
|
||||||
|
|
||||||
|
return ButtonKind.CreateMaterial
|
||||||
|
})
|
||||||
|
|
||||||
|
const onClick = () => {
|
||||||
|
if (isntMaterialCreatePage.value) {
|
||||||
|
router.push({name: 'materials-create'})
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prepareEditorData()
|
||||||
|
}
|
||||||
|
|
||||||
|
const prepareEditorData = async() => {
|
||||||
|
const json = editor?.value?.getJSON()
|
||||||
|
const html = editor?.value?.getHTML()
|
||||||
|
if (!json || !html) return;
|
||||||
|
const title = json.content?.find(el => el.type === 'heading' && el.attrs?.level === 1)?.content?.[0]?.text
|
||||||
|
const shortDescription = json.content?.find(el => el.type === 'paragraph')?.content?.[0]?.text
|
||||||
|
const sendData: MaterialSendDto = {
|
||||||
|
title,
|
||||||
|
short_description: shortDescription,
|
||||||
|
datetime: dayjs().format('YYYY-MM-DDTHH:mm:ss.sssZ'),
|
||||||
|
description_json: json,
|
||||||
|
description_html: html
|
||||||
|
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
isLoadingSave.value = true
|
||||||
|
const {data,error} = await useFetch('/api/save', {
|
||||||
|
method: 'POST',
|
||||||
|
body: sendData
|
||||||
|
})
|
||||||
|
if (data.value) {
|
||||||
|
router.push({name: 'materials-id', params: {id: data.value.id}})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
|
||||||
|
} finally {
|
||||||
|
isLoadingSave.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-header">
|
||||||
|
<div class="header">
|
||||||
|
<NuxtLink to="/" class="link">
|
||||||
|
<IconLogo class="icon" />
|
||||||
|
</NuxtLink>
|
||||||
|
<div>
|
||||||
|
<Button.ButtonDefault :kind="getButtonKind" @click="onClick" :loading="isLoadingSave" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.header {
|
||||||
|
max-width: 1224px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 36px;
|
||||||
|
background: #fff;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
height: 44px;
|
||||||
|
width: 115px;
|
||||||
|
color: var(--text-blue);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-header {
|
||||||
|
background: #fff;
|
||||||
|
width: 100dvw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.link {
|
||||||
|
line-height: 80%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.header {
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
height: 36px;
|
||||||
|
width: 93px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
8
app/components/icons/iconAdd.vue
Normal file
8
app/components/icons/iconAdd.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18 12.75H6C5.59 12.75 5.25 12.41 5.25 12C5.25 11.59 5.59 11.25 6 11.25H18C18.41 11.25 18.75 11.59 18.75 12C18.75 12.41 18.41 12.75 18 12.75Z" fill="currentColor"/>
|
||||||
|
<path d="M12 18.75C11.59 18.75 11.25 18.41 11.25 18V6C11.25 5.59 11.59 5.25 12 5.25C12.41 5.25 12.75 5.59 12.75 6V18C12.75 18.41 12.41 18.75 12 18.75Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
</template>
|
||||||
8
app/components/icons/iconArrowLeft.vue
Normal file
8
app/components/icons/iconArrowLeft.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M9.57 18.82C9.38 18.82 9.19 18.75 9.04 18.6L2.97 12.53C2.68 12.24 2.68 11.76 2.97 11.47L9.04 5.4C9.33 5.11 9.81 5.11 10.1 5.4C10.39 5.69 10.39 6.17 10.1 6.46L4.56 12L10.1 17.54C10.39 17.83 10.39 18.31 10.1 18.6C9.96 18.75 9.76 18.82 9.57 18.82Z" fill="currentColor"/>
|
||||||
|
<path d="M20.5 12.75H3.67C3.26 12.75 2.92 12.41 2.92 12C2.92 11.59 3.26 11.25 3.67 11.25H20.5C20.91 11.25 21.25 11.59 21.25 12C21.25 12.41 20.91 12.75 20.5 12.75Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
</template>
|
||||||
8
app/components/icons/iconArrowRight.vue
Normal file
8
app/components/icons/iconArrowRight.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14.43 18.82C14.24 18.82 14.05 18.75 13.9 18.6C13.61 18.31 13.61 17.83 13.9 17.54L19.44 12L13.9 6.46C13.61 6.17 13.61 5.69 13.9 5.4C14.19 5.11 14.67 5.11 14.96 5.4L21.03 11.47C21.32 11.76 21.32 12.24 21.03 12.53L14.96 18.6C14.81 18.75 14.62 18.82 14.43 18.82Z" fill="currentColor"/>
|
||||||
|
<path d="M20.33 12.75H3.5C3.09 12.75 2.75 12.41 2.75 12C2.75 11.59 3.09 11.25 3.5 11.25H20.33C20.74 11.25 21.08 11.59 21.08 12C21.08 12.41 20.74 12.75 20.33 12.75Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
</template>
|
||||||
7
app/components/icons/iconArrowSquareLeft.vue
Normal file
7
app/components/icons/iconArrowSquareLeft.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M15 22.75H9C3.57 22.75 1.25 20.43 1.25 15V9C1.25 3.57 3.57 1.25 9 1.25H15C20.43 1.25 22.75 3.57 22.75 9V15C22.75 20.43 20.43 22.75 15 22.75ZM9 2.75C4.39 2.75 2.75 4.39 2.75 9V15C2.75 19.61 4.39 21.25 9 21.25H15C19.61 21.25 21.25 19.61 21.25 15V9C21.25 4.39 19.61 2.75 15 2.75H9Z" fill="currentColor"/>
|
||||||
|
<path d="M13.26 16.28C13.07 16.28 12.88 16.21 12.73 16.06L9.2 12.53C8.91 12.24 8.91 11.76 9.2 11.47L12.73 7.94C13.02 7.65 13.5 7.65 13.79 7.94C14.08 8.23 14.08 8.71 13.79 9L10.79 12L13.79 15C14.08 15.29 14.08 15.77 13.79 16.06C13.65 16.21 13.46 16.28 13.26 16.28Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
</template>
|
||||||
8
app/components/icons/iconArrowSquareRight.vue
Normal file
8
app/components/icons/iconArrowSquareRight.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M15 22.75H9C3.57 22.75 1.25 20.43 1.25 15V9C1.25 3.57 3.57 1.25 9 1.25H15C20.43 1.25 22.75 3.57 22.75 9V15C22.75 20.43 20.43 22.75 15 22.75ZM9 2.75C4.39 2.75 2.75 4.39 2.75 9V15C2.75 19.61 4.39 21.25 9 21.25H15C19.61 21.25 21.25 19.61 21.25 15V9C21.25 4.39 19.61 2.75 15 2.75H9Z" fill="currentColor"/>
|
||||||
|
<path d="M10.74 16.28C10.55 16.28 10.36 16.21 10.21 16.06C9.92 15.77 9.92 15.29 10.21 15L13.21 12L10.21 9C9.92 8.71 9.92 8.23 10.21 7.94C10.5 7.65 10.98 7.65 11.27 7.94L14.8 11.47C15.09 11.76 15.09 12.24 14.8 12.53L11.27 16.06C11.12 16.21 10.93 16.28 10.74 16.28Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
</template>
|
||||||
15
app/components/icons/iconCalendar.vue
Normal file
15
app/components/icons/iconCalendar.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<template>
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M8 5.75C7.59 5.75 7.25 5.41 7.25 5V2C7.25 1.59 7.59 1.25 8 1.25C8.41 1.25 8.75 1.59 8.75 2V5C8.75 5.41 8.41 5.75 8 5.75Z" fill="currentColor"/>
|
||||||
|
<path d="M16 5.75C15.59 5.75 15.25 5.41 15.25 5V2C15.25 1.59 15.59 1.25 16 1.25C16.41 1.25 16.75 1.59 16.75 2V5C16.75 5.41 16.41 5.75 16 5.75Z" fill="currentColor"/>
|
||||||
|
<path d="M8.5 14.5C8.37 14.5 8.24 14.47 8.12 14.42C7.99 14.37 7.89 14.3 7.79 14.21C7.61 14.02 7.5 13.77 7.5 13.5C7.5 13.37 7.53 13.24 7.58 13.12C7.63 13 7.7 12.89 7.79 12.79C7.89 12.7 7.99 12.63 8.12 12.58C8.48 12.43 8.93 12.51 9.21 12.79C9.39 12.98 9.5 13.24 9.5 13.5C9.5 13.56 9.49 13.63 9.48 13.7C9.47 13.76 9.45 13.82 9.42 13.88C9.4 13.94 9.37 14 9.33 14.06C9.3 14.11 9.25 14.16 9.21 14.21C9.02 14.39 8.76 14.5 8.5 14.5Z" fill="currentColor"/>
|
||||||
|
<path d="M12 14.5C11.87 14.5 11.74 14.47 11.62 14.42C11.49 14.37 11.39 14.3 11.29 14.21C11.11 14.02 11 13.77 11 13.5C11 13.37 11.03 13.24 11.08 13.12C11.13 13 11.2 12.89 11.29 12.79C11.39 12.7 11.49 12.63 11.62 12.58C11.98 12.42 12.43 12.51 12.71 12.79C12.89 12.98 13 13.24 13 13.5C13 13.56 12.99 13.63 12.98 13.7C12.97 13.76 12.95 13.82 12.92 13.88C12.9 13.94 12.87 14 12.83 14.06C12.8 14.11 12.75 14.16 12.71 14.21C12.52 14.39 12.26 14.5 12 14.5Z" fill="currentColor"/>
|
||||||
|
<path d="M15.5 14.5C15.37 14.5 15.24 14.47 15.12 14.42C14.99 14.37 14.89 14.3 14.79 14.21C14.75 14.16 14.71 14.11 14.67 14.06C14.63 14 14.6 13.94 14.58 13.88C14.55 13.82 14.53 13.76 14.52 13.7C14.51 13.63 14.5 13.56 14.5 13.5C14.5 13.24 14.61 12.98 14.79 12.79C14.89 12.7 14.99 12.63 15.12 12.58C15.49 12.42 15.93 12.51 16.21 12.79C16.39 12.98 16.5 13.24 16.5 13.5C16.5 13.56 16.49 13.63 16.48 13.7C16.47 13.76 16.45 13.82 16.42 13.88C16.4 13.94 16.37 14 16.33 14.06C16.3 14.11 16.25 14.16 16.21 14.21C16.02 14.39 15.76 14.5 15.5 14.5Z" fill="currentColor"/>
|
||||||
|
<path d="M8.5 18C8.37 18 8.24 17.97 8.12 17.92C8 17.87 7.89 17.8 7.79 17.71C7.61 17.52 7.5 17.26 7.5 17C7.5 16.87 7.53 16.74 7.58 16.62C7.63 16.49 7.7 16.38 7.79 16.29C8.16 15.92 8.84 15.92 9.21 16.29C9.39 16.48 9.5 16.74 9.5 17C9.5 17.26 9.39 17.52 9.21 17.71C9.02 17.89 8.76 18 8.5 18Z" fill="currentColor"/>
|
||||||
|
<path d="M12 18C11.74 18 11.48 17.89 11.29 17.71C11.11 17.52 11 17.26 11 17C11 16.87 11.03 16.74 11.08 16.62C11.13 16.49 11.2 16.38 11.29 16.29C11.66 15.92 12.34 15.92 12.71 16.29C12.8 16.38 12.87 16.49 12.92 16.62C12.97 16.74 13 16.87 13 17C13 17.26 12.89 17.52 12.71 17.71C12.52 17.89 12.26 18 12 18Z" fill="currentColor"/>
|
||||||
|
<path d="M15.5 18C15.24 18 14.98 17.89 14.79 17.71C14.7 17.62 14.63 17.51 14.58 17.38C14.53 17.26 14.5 17.13 14.5 17C14.5 16.87 14.53 16.74 14.58 16.62C14.63 16.49 14.7 16.38 14.79 16.29C15.02 16.06 15.37 15.95 15.69 16.02C15.76 16.03 15.82 16.05 15.88 16.08C15.94 16.1 16 16.13 16.06 16.17C16.11 16.2 16.16 16.25 16.21 16.29C16.39 16.48 16.5 16.74 16.5 17C16.5 17.26 16.39 17.52 16.21 17.71C16.02 17.89 15.76 18 15.5 18Z" fill="currentColor"/>
|
||||||
|
<path d="M20.5 9.83997H3.5C3.09 9.83997 2.75 9.49997 2.75 9.08997C2.75 8.67997 3.09 8.33997 3.5 8.33997H20.5C20.91 8.33997 21.25 8.67997 21.25 9.08997C21.25 9.49997 20.91 9.83997 20.5 9.83997Z" fill="currentColor"/>
|
||||||
|
<path d="M16 22.75H8C4.35 22.75 2.25 20.65 2.25 17V8.5C2.25 4.85 4.35 2.75 8 2.75H16C19.65 2.75 21.75 4.85 21.75 8.5V17C21.75 20.65 19.65 22.75 16 22.75ZM8 4.25C5.14 4.25 3.75 5.64 3.75 8.5V17C3.75 19.86 5.14 21.25 8 21.25H16C18.86 21.25 20.25 19.86 20.25 17V8.5C20.25 5.64 18.86 4.25 16 4.25H8Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
</template>
|
||||||
7
app/components/icons/iconLoader.vue
Normal file
7
app/components/icons/iconLoader.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M18.6696 7.58373C18.9869 8.72224 19.0768 9.91209 18.9343 11.0854C18.7918 12.2586 18.4196 13.3923 17.8389 14.4218C17.2582 15.4512 16.4805 16.3561 15.55 17.085C14.6196 17.8138 13.5548 18.3523 12.4163 18.6696C11.2778 18.9869 10.0879 19.0768 8.91463 18.9343C7.74136 18.7918 6.60766 18.4196 5.57824 17.8389C4.54882 17.2582 3.64386 16.4805 2.91502 15.55C2.18617 14.6196 1.64773 13.5548 1.33042 12.4163" stroke="#00B2FF" stroke-width="2"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
</template>
|
||||||
20
app/components/icons/iconLogo.vue
Normal file
20
app/components/icons/iconLogo.vue
Normal file
File diff suppressed because one or more lines are too long
8
app/components/icons/iconSend.vue
Normal file
8
app/components/icons/iconSend.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M14.22 21.63C13.04 21.63 11.37 20.8 10.05 16.83L9.33 14.67L7.17 13.95C3.21 12.63 2.38 10.96 2.38 9.78001C2.38 8.61001 3.21 6.93001 7.17 5.60001L15.66 2.77001C17.78 2.06001 19.55 2.27001 20.64 3.35001C21.73 4.43001 21.94 6.21001 21.23 8.33001L18.4 16.82C17.07 20.8 15.4 21.63 14.22 21.63ZM7.64 7.03001C4.86 7.96001 3.87 9.06001 3.87 9.78001C3.87 10.5 4.86 11.6 7.64 12.52L10.16 13.36C10.38 13.43 10.56 13.61 10.63 13.83L11.47 16.35C12.39 19.13 13.5 20.12 14.22 20.12C14.94 20.12 16.04 19.13 16.97 16.35L19.8 7.86001C20.31 6.32001 20.22 5.06001 19.57 4.41001C18.92 3.76001 17.66 3.68001 16.13 4.19001L7.64 7.03001Z" fill="currentColor"/>
|
||||||
|
<path d="M10.11 14.4C9.92 14.4 9.73001 14.33 9.58001 14.18C9.29001 13.89 9.29001 13.41 9.58001 13.12L13.16 9.53001C13.45 9.24001 13.93 9.24001 14.22 9.53001C14.51 9.82001 14.51 10.3 14.22 10.59L10.64 14.18C10.5 14.33 10.3 14.4 10.11 14.4Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
</template>
|
||||||
8
app/components/icons/iconTickCircle.vue
Normal file
8
app/components/icons/iconTickCircle.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 22.75C6.07 22.75 1.25 17.93 1.25 12C1.25 6.07 6.07 1.25 12 1.25C17.93 1.25 22.75 6.07 22.75 12C22.75 17.93 17.93 22.75 12 22.75ZM12 2.75C6.9 2.75 2.75 6.9 2.75 12C2.75 17.1 6.9 21.25 12 21.25C17.1 21.25 21.25 17.1 21.25 12C21.25 6.9 17.1 2.75 12 2.75Z" fill="currentColor"/>
|
||||||
|
<path d="M10.58 15.58C10.38 15.58 10.19 15.5 10.05 15.36L7.22 12.53C6.93 12.24 6.93 11.76 7.22 11.47C7.51 11.18 7.99 11.18 8.28 11.47L10.58 13.77L15.72 8.63C16.01 8.34 16.49 8.34 16.78 8.63C17.07 8.92 17.07 9.4 16.78 9.69L11.11 15.36C10.97 15.5 10.78 15.58 10.58 15.58Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
</template>
|
||||||
8
app/components/icons/iconTickSquare.vue
Normal file
8
app/components/icons/iconTickSquare.vue
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<template>
|
||||||
|
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M15 22.75H9C3.57 22.75 1.25 20.43 1.25 15V9C1.25 3.57 3.57 1.25 9 1.25H15C20.43 1.25 22.75 3.57 22.75 9V15C22.75 20.43 20.43 22.75 15 22.75ZM9 2.75C4.39 2.75 2.75 4.39 2.75 9V15C2.75 19.61 4.39 21.25 9 21.25H15C19.61 21.25 21.25 19.61 21.25 15V9C21.25 4.39 19.61 2.75 15 2.75H9Z" fill="currentColor"/>
|
||||||
|
<path d="M10.58 15.58C10.38 15.58 10.19 15.5 10.05 15.36L7.22 12.53C6.93 12.24 6.93 11.76 7.22 11.47C7.51 11.18 7.99 11.18 8.28 11.47L10.58 13.77L15.72 8.63C16.01 8.34 16.49 8.34 16.78 8.63C17.07 8.92 17.07 9.4 16.78 9.69L11.11 15.36C10.97 15.5 10.78 15.58 10.58 15.58Z" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
</template>
|
||||||
47
app/components/ui/Buttons/ButtonBack.vue
Normal file
47
app/components/ui/Buttons/ButtonBack.vue
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<script lang="ts" setup="">
|
||||||
|
import {useGoBackOrHome} from "~/composables/useGoBackOrHome";
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const {goBack, canGoBack} = useGoBackOrHome()
|
||||||
|
const onClickBack = () => {
|
||||||
|
if (canGoBack.value) {
|
||||||
|
goBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button type="button" class="button" @click="onClickBack">
|
||||||
|
<IconsIconArrowLeft class="icon"/>
|
||||||
|
<span class="text" >Назад</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.button {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-black);
|
||||||
|
transition: all .3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--text-blue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
@include mixins.square(24px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
font-family: FuturaPT, sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
110
app/components/ui/Buttons/ButtonDefault.vue
Normal file
110
app/components/ui/Buttons/ButtonDefault.vue
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import IconLoader from "~/components/icons/iconLoader.vue";
|
||||||
|
import type {ButtonProps} from "~/components/ui/Buttons/types.buttons";
|
||||||
|
import {ButtonKind} from "~/components/ui/Buttons/constants.buttons";
|
||||||
|
import IconAdd from "~/components/icons/iconAdd.vue";
|
||||||
|
import IconSend from "~/components/icons/iconSend.vue";
|
||||||
|
import {useWindowSize} from "@vueuse/core";
|
||||||
|
|
||||||
|
const {width} = useWindowSize()
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ButtonProps>(), {
|
||||||
|
loading: false,
|
||||||
|
kind: ButtonKind.CreateMaterial
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
const getButtonTextAndIcon = computed(() => {
|
||||||
|
switch (props.kind) {
|
||||||
|
case ButtonKind.CreateStory:
|
||||||
|
return {
|
||||||
|
text: 'Создать историю',
|
||||||
|
icon: IconAdd,
|
||||||
|
};
|
||||||
|
case ButtonKind.CreateMaterial:
|
||||||
|
return {
|
||||||
|
text: 'Создать материал',
|
||||||
|
icon: IconAdd,
|
||||||
|
};
|
||||||
|
case ButtonKind.Send:
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
text: 'Сохранить',
|
||||||
|
icon: IconSend
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button type="button" class="button">
|
||||||
|
<IconLoader class="icon loader" :class="{loading}" v-if="loading" />
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<span v-if="width > 768">{{getButtonTextAndIcon.text}}</span>
|
||||||
|
<component v-else :is="getButtonTextAndIcon.icon" class="icon"/>
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.button {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0 24px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: #E7F8FE;
|
||||||
|
color: var(--text-blue);
|
||||||
|
|
||||||
|
font-family: FuturaPT, sans-serif;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 100%;
|
||||||
|
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all .3s ease;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: #D2F3FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #E7F8FE;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.loading {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
@include mixins.square(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
animation: rotate 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate {
|
||||||
|
0% {
|
||||||
|
rotate: 0deg;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
rotate: 360deg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.button {
|
||||||
|
padding: 10px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
@include mixins.square(28px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
app/components/ui/Buttons/constants.buttons.ts
Normal file
5
app/components/ui/Buttons/constants.buttons.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export enum ButtonKind {
|
||||||
|
Send = 'send',
|
||||||
|
CreateStory = 'createStory',
|
||||||
|
CreateMaterial = 'createMaterial'
|
||||||
|
}
|
||||||
7
app/components/ui/Buttons/index.ts
Normal file
7
app/components/ui/Buttons/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import ButtonBack from "~/components/ui/Buttons/ButtonBack.vue";
|
||||||
|
import ButtonDefault from "~/components/ui/Buttons/ButtonDefault.vue";
|
||||||
|
|
||||||
|
export const Button = {
|
||||||
|
ButtonBack: ButtonBack,
|
||||||
|
ButtonDefault: ButtonDefault
|
||||||
|
}
|
||||||
7
app/components/ui/Buttons/types.buttons.ts
Normal file
7
app/components/ui/Buttons/types.buttons.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import {ButtonBackTheme, ButtonKind} from "~/components/ui/Buttons/constants.buttons";
|
||||||
|
|
||||||
|
export interface ButtonProps {
|
||||||
|
loading?: boolean
|
||||||
|
kind?: ButtonKind,
|
||||||
|
}
|
||||||
|
|
||||||
20
app/composables/useGoBackOrHome.ts
Normal file
20
app/composables/useGoBackOrHome.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
export const useGoBackOrHome = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const canGoBack = ref(false);
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
canGoBack.value = window.history.length > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
canGoBack.value && window.history.length > 1
|
||||||
|
? router.back()
|
||||||
|
: router.push('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
return { goBack, canGoBack };
|
||||||
|
};
|
||||||
37
app/layouts/default.vue
Normal file
37
app/layouts/default.vue
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script lang="ts" setup="">
|
||||||
|
|
||||||
|
import BaseHeader from "~/components/baseHeader.vue";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseHeader />
|
||||||
|
<div class="content-bg">
|
||||||
|
<div class="content-container">
|
||||||
|
<div class="content">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.content-container {
|
||||||
|
max-width: 1224px;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 100dvh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-bg {
|
||||||
|
background: #F4F6FB;
|
||||||
|
padding-block: 48px;
|
||||||
|
padding-inline: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.content-bg {
|
||||||
|
background: #F4F6FB;
|
||||||
|
padding-block: 32px;
|
||||||
|
padding-inline: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
5
app/middleware/redirectFromMainPage.ts
Normal file
5
app/middleware/redirectFromMainPage.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default defineNuxtRouteMiddleware((to, _from) => {
|
||||||
|
if (to.path === '/') {
|
||||||
|
return navigateTo({name: 'materials' })
|
||||||
|
}
|
||||||
|
})
|
||||||
16
app/pages/index.vue
Normal file
16
app/pages/index.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts" setup="">
|
||||||
|
definePageMeta({
|
||||||
|
middleware: ['redirect-from-main-page']
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
main page
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
|
||||||
|
</style>
|
||||||
128
app/pages/materials/[id].vue
Normal file
128
app/pages/materials/[id].vue
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
<script lang="ts" setup="">
|
||||||
|
import {useMaterialsStore} from "~/stores/materials/useMaterialsStore";
|
||||||
|
import {Button} from "~/components/ui/Buttons";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import type {Material} from "~/stores/materials/types.materials";
|
||||||
|
import sanitizeHtml from "sanitize-html";
|
||||||
|
import IconCalendar from "~/components/icons/iconCalendar.vue";
|
||||||
|
import ru from 'dayjs/locale/ru'
|
||||||
|
|
||||||
|
dayjs.locale(ru);
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const materialsStore = useMaterialsStore()
|
||||||
|
|
||||||
|
const currentItem = computed<Material | undefined>(() => materialsStore.getById(route.params.id))
|
||||||
|
const date = computed(() => dayjs(currentItem.value?.datetime).format('D MMMM'))
|
||||||
|
const safeHTML = computed(() => {
|
||||||
|
if (!currentItem.value) return ''
|
||||||
|
return sanitizeHtml(currentItem.value.description_html)
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
await useAsyncData(async () => {
|
||||||
|
await materialsStore.fetchMaterials()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="material-container">
|
||||||
|
<Button.ButtonBack/>
|
||||||
|
|
||||||
|
<div class="material-content">
|
||||||
|
<div class="date-container">
|
||||||
|
<IconCalendar class="icon" />
|
||||||
|
<span class="date">{{date}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content" v-html="safeHTML" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.material-container {
|
||||||
|
max-width: 1014px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-content {
|
||||||
|
margin-top: 48px;
|
||||||
|
padding: 64px;
|
||||||
|
border-radius: 32px;
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
:deep(h1), :deep(h2), :deep(h3), :deep(p), :deep(blockquote) {
|
||||||
|
margin: 0;
|
||||||
|
padding-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(h1) {
|
||||||
|
font-family: SourceSans3, monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 36px;
|
||||||
|
line-height: 120%;
|
||||||
|
color: var(--text-black);
|
||||||
|
}
|
||||||
|
:deep(p:first-of-type) {
|
||||||
|
font-family: FuturaPT, monospace;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 120%;
|
||||||
|
color: var(--text-black);
|
||||||
|
}
|
||||||
|
:deep(p:not(:first-of-type)) {
|
||||||
|
font-family: FuturaPT, monospace;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 120%;
|
||||||
|
color: var(--text-black);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.date-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-light-gray);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
@include mixins.square(20px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
font-family: FuturaPT;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.material-container {
|
||||||
|
padding-inline: 0;
|
||||||
|
}
|
||||||
|
.material-content {
|
||||||
|
margin-top: 32px;
|
||||||
|
padding: 24px 16px;
|
||||||
|
border-radius: 24px;
|
||||||
|
position: relative;
|
||||||
|
left: -16px;
|
||||||
|
width: calc(100dvw - (16px * 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
:deep(h1) {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
:deep(p:first-of-type) {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
:deep(p:not(:first-of-type)) {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
45
app/pages/materials/create.vue
Normal file
45
app/pages/materials/create.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script lang="ts" setup="">
|
||||||
|
import {Button} from "~/components/ui/Buttons";
|
||||||
|
import {useWindowSize} from "@vueuse/core";
|
||||||
|
|
||||||
|
const {width} = useWindowSize()
|
||||||
|
const router = useRouter()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="create-container">
|
||||||
|
<Button.ButtonBack v-if="width > 768"/>
|
||||||
|
<h1 class="h1">Создание материала</h1>
|
||||||
|
<TiptapEditor class="editor" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.create-container {
|
||||||
|
max-width: 1014px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.h1 {
|
||||||
|
font-family: SourceSans3, sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 36px;
|
||||||
|
line-height: 120%;
|
||||||
|
padding-top: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
padding-top: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.h1 {
|
||||||
|
padding-top: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
padding-top: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
60
app/pages/materials/index.vue
Normal file
60
app/pages/materials/index.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script lang="ts" setup="">
|
||||||
|
import MaterialsItem from "~/components/MaterialsItem.vue";
|
||||||
|
import {useMaterialsStore} from "~/stores/materials/useMaterialsStore";
|
||||||
|
|
||||||
|
const materialsStore = useMaterialsStore()
|
||||||
|
await useAsyncData(async () => {
|
||||||
|
await materialsStore.fetchMaterials()
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1 class="h1">Материалы</h1>
|
||||||
|
|
||||||
|
<div v-if="materialsStore.errorMaterials" class="error">Упс... Данные не загрузились :(</div>
|
||||||
|
<div v-else class="materials">
|
||||||
|
<template
|
||||||
|
v-for="item of materialsStore.getMaterialsSorted" :key="item.id"
|
||||||
|
>
|
||||||
|
|
||||||
|
<NuxtLink
|
||||||
|
:to="{
|
||||||
|
name: 'materials-id', params: {id: item.id }
|
||||||
|
}"
|
||||||
|
target="_blank"
|
||||||
|
class="link"
|
||||||
|
>
|
||||||
|
<MaterialsItem :item />
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.h1 {
|
||||||
|
font-family: SourceSans3, sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 36px;
|
||||||
|
line-height: 120%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.materials {
|
||||||
|
padding-top: 48px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 40px;
|
||||||
|
}
|
||||||
|
.link {
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--text-black);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 768px) {
|
||||||
|
.materials {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1
app/shared/constants.ts
Normal file
1
app/shared/constants.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const EDITOR_KEY = Symbol('tiptap-editor')
|
||||||
15
app/stores/materials/types.materials.ts
Normal file
15
app/stores/materials/types.materials.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export interface MaterialsState {
|
||||||
|
isLoadingMaterials: boolean,
|
||||||
|
errorMaterials: null | string,
|
||||||
|
materials: Material[],
|
||||||
|
}
|
||||||
|
export interface Material {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
short_description: string;
|
||||||
|
datetime: string;
|
||||||
|
description_html: string
|
||||||
|
description_json: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MaterialSendDto = Omit<Material, 'id' | 'description_html'> & Partial<Pick<Material, 'description_html'>>;
|
||||||
35
app/stores/materials/useMaterialsStore.ts
Normal file
35
app/stores/materials/useMaterialsStore.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type {Material, MaterialsState} from "~/stores/materials/types.materials";
|
||||||
|
import { orderBy } from 'lodash-es'
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
export const useMaterialsStore = defineStore('materials', {
|
||||||
|
state: (): MaterialsState => ({
|
||||||
|
isLoadingMaterials: false,
|
||||||
|
errorMaterials: null,
|
||||||
|
materials: [],
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
getMaterialsSorted: (state) => orderBy(state.materials, (item) => dayjs(item.datetime).valueOf(), 'desc'),
|
||||||
|
getById: (state) => (id:string): Material | undefined => state.materials.find(item => item.id === +id)
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async fetchMaterials() {
|
||||||
|
this.isLoadingMaterials = true
|
||||||
|
this.errorMaterials = null
|
||||||
|
try {
|
||||||
|
const {data} = await useFetch<Material[]>('/api', {key: 'fetch-materials'})
|
||||||
|
if (data.value) {
|
||||||
|
this.materials = data.value
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Error:', e)
|
||||||
|
this.errorMaterials = String(e)
|
||||||
|
} finally {
|
||||||
|
this.isLoadingMaterials = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
}
|
||||||
|
})
|
||||||
39
eslint.config.mjs
Normal file
39
eslint.config.mjs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// @ts-check
|
||||||
|
import withNuxt from './.nuxt/eslint.config.mjs';
|
||||||
|
import vueEslintParser from 'vue-eslint-parser';
|
||||||
|
import tsEslintPlugin from '@typescript-eslint/eslint-plugin';
|
||||||
|
import tsParser from '@typescript-eslint/parser';
|
||||||
|
import eslintPluginVue from 'eslint-plugin-vue';
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
import { dirname } from 'path'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
|
||||||
|
export default withNuxt({
|
||||||
|
files: ['**/*.vue'],
|
||||||
|
languageOptions: {
|
||||||
|
parser: vueEslintParser,
|
||||||
|
parserOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
extraFileExtensions: ['.vue'],
|
||||||
|
project: './tsconfig.json',
|
||||||
|
tsconfigRootDir: __dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
vue: eslintPluginVue,
|
||||||
|
'@typescript-eslint': tsEslintPlugin,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': 'warn',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'vue/multi-word-component-names': 0,
|
||||||
|
},
|
||||||
|
ignores: [
|
||||||
|
'.nuxt/**',
|
||||||
|
'.output/**',
|
||||||
|
'node_modules',
|
||||||
|
],
|
||||||
|
});
|
||||||
33
nuxt.config.ts
Normal file
33
nuxt.config.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import autoprefixer from "autoprefixer";
|
||||||
|
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2025-07-15',
|
||||||
|
devtools: { enabled: true },
|
||||||
|
css: [
|
||||||
|
'@/assets/styles/tiptap.scss'
|
||||||
|
],
|
||||||
|
vite: {
|
||||||
|
css: {
|
||||||
|
postcss: {
|
||||||
|
plugins: [autoprefixer({})],
|
||||||
|
},
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
additionalData: '@use "~/assets/styles/mixins.scss";',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
assetsInlineLimit: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modules: ['@nuxt/eslint', '@pinia/nuxt', 'nuxt-tiptap-editor'],
|
||||||
|
tiptap: {
|
||||||
|
prefix: 'Tiptap',
|
||||||
|
},
|
||||||
|
nitro: {
|
||||||
|
routeRules: {
|
||||||
|
"/api/**": { proxy: `${process.env.NUXT_PUBLIC_API_URL}/**` },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
53
package.json
Normal file
53
package.json
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"name": "dtv",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"engines": {
|
||||||
|
"node": "24.11.0",
|
||||||
|
"pnpm": "10.20.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare",
|
||||||
|
"clean": "./node_modules/.bin/rimraf ./dist ./.output ./.nuxt",
|
||||||
|
"server": "pnpm run build && node .output/server/index.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxt/eslint": "1.10.0",
|
||||||
|
"@pinia/nuxt": "0.11.2",
|
||||||
|
"@tiptap/core": "^3.10.0",
|
||||||
|
"@tiptap/extension-list": "^3.10.0",
|
||||||
|
"@tiptap/extension-paragraph": "^3.10.0",
|
||||||
|
"@tiptap/extension-text-style": "^3.10.0",
|
||||||
|
"@tiptap/extensions": "^3.10.0",
|
||||||
|
"@tiptap/pm": "^3.10.0",
|
||||||
|
"@tiptap/starter-kit": "^3.10.0",
|
||||||
|
"@tiptap/vue-3": "^3.10.0",
|
||||||
|
"@vueuse/core": "^14.0.0",
|
||||||
|
"dayjs": "^1.11.18",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"nuxt": "^4.2.0",
|
||||||
|
"nuxt-tiptap-editor": "2.3.1",
|
||||||
|
"pinia": "^3.0.3",
|
||||||
|
"rimraf": "^6.0.1",
|
||||||
|
"sanitize-html": "^2.17.0",
|
||||||
|
"vue": "^3.5.22",
|
||||||
|
"vue-router": "^4.6.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
"@types/node": "^24.9.2",
|
||||||
|
"@types/sanitize-html": "^2.16.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.46.2",
|
||||||
|
"@typescript-eslint/parser": "^8.46.2",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"eslint": "^9.38.0",
|
||||||
|
"eslint-plugin-vue": "^10.5.1",
|
||||||
|
"sass-embedded": "^1.93.2",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vue-eslint-parser": "^10.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
9833
pnpm-lock.yaml
generated
Normal file
9833
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-Agent: *
|
||||||
|
Disallow:
|
||||||
19
tsconfig.json
Normal file
19
tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"files": [],
|
||||||
|
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.server.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.shared.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user