This commit is contained in:
2025-10-31 19:10:01 +07:00
commit e54eb70c86
50 changed files with 11579 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
NUXT_PUBLIC_ALIAS=
NUXT_PUBLIC_API_URL="https://some.ru/${NUXT_PUBLIC_ALIAS}"

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,2 @@
@use "fonts/futuraPT";
@use "fonts/sourceSans";

View 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');
}

View 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');
}

View 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;
}

View File

@@ -0,0 +1,6 @@
@mixin square($size: 10px) {
width: $size;
min-width: $size;
height: $size;
min-height: $size;
}

View 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;
}
}

View File

@@ -0,0 +1,7 @@
:root {
--text-blue: #00B2FF;
--text-black: #14142A;
--text-gray: #81859C;
--text-light-gray: #BEC2DA;
}

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

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

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

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

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

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

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

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

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

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

File diff suppressed because one or more lines are too long

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

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

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

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

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

View File

@@ -0,0 +1,5 @@
export enum ButtonKind {
Send = 'send',
CreateStory = 'createStory',
CreateMaterial = 'createMaterial'
}

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

View File

@@ -0,0 +1,7 @@
import {ButtonBackTheme, ButtonKind} from "~/components/ui/Buttons/constants.buttons";
export interface ButtonProps {
loading?: boolean
kind?: ButtonKind,
}

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

View File

@@ -0,0 +1,5 @@
export default defineNuxtRouteMiddleware((to, _from) => {
if (to.path === '/') {
return navigateTo({name: 'materials' })
}
})

16
app/pages/index.vue Normal file
View 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>

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

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

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

@@ -0,0 +1 @@
export const EDITOR_KEY = Symbol('tiptap-editor')

View 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'>>;

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

File diff suppressed because it is too large Load Diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-Agent: *
Disallow:

19
tsconfig.json Normal file
View 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"
}
]
}