Доработан импорт статей, сделано разделение пользователей на собственно пользователей и авторов. Планируется сделать разные типы авторов.

This commit is contained in:
anibilag 2025-05-21 23:04:36 +03:00
parent 4663e0a300
commit 7dbfb0323c
27 changed files with 1435 additions and 103 deletions

228
package-lock.json generated
View File

@ -10,6 +10,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.525.0",
"@aws-sdk/s3-request-presigner": "^3.525.0",
"@headlessui/react": "^2.2.2",
"@prisma/client": "^6.2.1",
"@tiptap/extension-highlight": "^2.11.5",
"@tiptap/extension-image": "^2.11.5",
@ -1810,6 +1811,79 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.0.tgz",
"integrity": "sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.0.tgz",
"integrity": "sha512-lGTor4VlXcesUMh1cupTUTDoCxMb0V6bm3CnxHzQcw8Eaf1jQbgQX4i02fYgT0vJ82tb5MZ4CZk1LRGkktJCzg==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.0",
"@floating-ui/utils": "^0.2.9"
}
},
"node_modules/@floating-ui/react": {
"version": "0.26.28",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.8",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.2.tgz",
"integrity": "sha512-06okr5cgPzMNBy+Ycse2A6udMi4bqwW/zgBF/rwjcNqWkyr82Mcg8b0vjX8OJpZFy/FKjJmw6wV7t44kK6kW7A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==",
"license": "MIT"
},
"node_modules/@headlessui/react": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.2.tgz",
"integrity": "sha512-zbniWOYBQ8GHSUIOPY7BbdIn6PzUOq0z41RFrF30HbjsxG6Rrfk+6QulR8Kgf2Vwj2a/rE6i62q5vo+2gI5dJA==",
"license": "MIT",
"dependencies": {
"@floating-ui/react": "^0.26.16",
"@react-aria/focus": "^3.17.1",
"@react-aria/interactions": "^3.21.3",
"@tanstack/react-virtual": "^3.13.6",
"use-sync-external-store": "^1.5.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -2078,6 +2152,103 @@
"@prisma/debug": "6.3.1"
}
},
"node_modules/@react-aria/focus": {
"version": "3.20.2",
"resolved": "https://registry.npmjs.org/@react-aria/focus/-/focus-3.20.2.tgz",
"integrity": "sha512-Q3rouk/rzoF/3TuH6FzoAIKrl+kzZi9LHmr8S5EqLAOyP9TXIKG34x2j42dZsAhrw7TbF9gA8tBKwnCNH4ZV+Q==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/interactions": "^3.25.0",
"@react-aria/utils": "^3.28.2",
"@react-types/shared": "^3.29.0",
"@swc/helpers": "^0.5.0",
"clsx": "^2.0.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/interactions": {
"version": "3.25.0",
"resolved": "https://registry.npmjs.org/@react-aria/interactions/-/interactions-3.25.0.tgz",
"integrity": "sha512-GgIsDLlO8rDU/nFn6DfsbP9rfnzhm8QFjZkB9K9+r+MTSCn7bMntiWQgMM+5O6BiA8d7C7x4zuN4bZtc0RBdXQ==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/ssr": "^3.9.8",
"@react-aria/utils": "^3.28.2",
"@react-stately/flags": "^3.1.1",
"@react-types/shared": "^3.29.0",
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/ssr": {
"version": "3.9.8",
"resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.8.tgz",
"integrity": "sha512-lQDE/c9uTfBSDOjaZUJS8xP2jCKVk4zjQeIlCH90xaLhHDgbpCdns3xvFpJJujfj3nI4Ll9K7A+ONUBDCASOuw==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
"engines": {
"node": ">= 12"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-aria/utils": {
"version": "3.28.2",
"resolved": "https://registry.npmjs.org/@react-aria/utils/-/utils-3.28.2.tgz",
"integrity": "sha512-J8CcLbvnQgiBn54eeEvQQbIOfBF3A1QizxMw9P4cl9MkeR03ug7RnjTIdJY/n2p7t59kLeAB3tqiczhcj+Oi5w==",
"license": "Apache-2.0",
"dependencies": {
"@react-aria/ssr": "^3.9.8",
"@react-stately/flags": "^3.1.1",
"@react-stately/utils": "^3.10.6",
"@react-types/shared": "^3.29.0",
"@swc/helpers": "^0.5.0",
"clsx": "^2.0.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-stately/flags": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.1.tgz",
"integrity": "sha512-XPR5gi5LfrPdhxZzdIlJDz/B5cBf63l4q6/AzNqVWFKgd0QqY5LvWJftXkklaIUpKSJkIKQb8dphuZXDtkWNqg==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@react-stately/utils": {
"version": "3.10.6",
"resolved": "https://registry.npmjs.org/@react-stately/utils/-/utils-3.10.6.tgz",
"integrity": "sha512-O76ip4InfTTzAJrg8OaZxKU4vvjMDOpfA/PGNOytiXwBbkct2ZeZwaimJ8Bt9W1bj5VsZ81/o/tW4BacbdDOMA==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@react-types/shared": {
"version": "3.29.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.29.0.tgz",
"integrity": "sha512-IDQYu/AHgZimObzCFdNl1LpZvQW/xcfLt3v20sorl5qRucDVj4S9os98sVTZ4IRIBjmS+MkjqpR5E70xan7ooA==",
"license": "Apache-2.0",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@remirror/core-constants": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
@ -3076,6 +3247,42 @@
"node": ">=18.0.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz",
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.6.tgz",
"integrity": "sha512-WT7nWs8ximoQ0CDx/ngoFP7HbQF9Q2wQe4nh2NB+u2486eX3nZRE40P9g6ccCVq7ZfTSH5gFOuCoVH5DLNS/aA==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.13.6"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.6",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.6.tgz",
"integrity": "sha512-cnQUeWnhNP8tJ4WsGcYiX24Gjkc9ALstLbHcBj1t3E7EimN6n6kHH+DPV4PpDnuw00NApQp+ViojMj1GRdwYQg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tiptap/core": {
"version": "2.11.5",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.11.5.tgz",
@ -4545,6 +4752,15 @@
"node": ">= 6"
}
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
@ -8034,6 +8250,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/tabbable": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
"integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
"license": "MIT"
},
"node_modules/tailwindcss": {
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
@ -8299,9 +8521,9 @@
}
},
"node_modules/use-sync-external-store": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz",
"integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
"integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"

View File

@ -19,6 +19,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.525.0",
"@aws-sdk/s3-request-presigner": "^3.525.0",
"@headlessui/react": "^2.2.2",
"@prisma/client": "^6.2.1",
"@tiptap/extension-highlight": "^2.11.5",
"@tiptap/extension-image": "^2.11.5",

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

1
public/sitemap.xml Normal file
View File

@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:news="http://www.google.com/schemas/sitemap-news/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"><url><loc>https://russcult.anibilag.ru/</loc><changefreq>daily</changefreq><priority>1.0</priority></url><url><loc>https://russcult.anibilag.ru/search</loc><changefreq>weekly</changefreq><priority>0.8</priority></url><url><loc>https://russcult.anibilag.ru/article/054af0fc-25e4-4169-88f4-38952742eb64</loc><lastmod>2025-01-12T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/0d854fd6-f846-4b33-a27f-01a49198fc6e</loc><lastmod>2025-02-12T08:31:07.750Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/0fa77c25-df32-482c-8e55-90cc5a9798fb</loc><lastmod>2025-02-17T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/141303a4-8932-4d5a-b7d9-0600f6eea996</loc><lastmod>2025-02-13T20:08:04.036Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/1431c796-bcac-4fc9-9482-705268629a0e</loc><lastmod>2025-01-12T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/22f74530-d68f-4d11-880e-f1beb42ddee8</loc><lastmod>2025-02-17T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/248c18f7-6252-415f-baae-96ad215a6c03</loc><lastmod>2025-02-18T10:21:12.746Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/2ba5bceb-95b1-45e4-ab49-2c84ff181d7c</loc><lastmod>2025-02-19T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/5093e84f-f3c3-4809-abe9-00b477e9efa9</loc><lastmod>2025-02-17T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/5453d8a9-2af8-49f1-bbca-cb362908d975</loc><lastmod>2025-02-12T20:12:40.391Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/58da4b5a-be98-4433-b77e-413540813688</loc><lastmod>2025-02-12T20:21:38.395Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/92f695f6-0492-42d7-b14e-c5c270ab6c0a</loc><lastmod>2025-02-17T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/article/ce8252e4-1e3f-4d10-9a48-334e48df29e9</loc><lastmod>2025-02-17T00:00:00.000Z</lastmod><changefreq>weekly</changefreq><priority>0.7</priority></url><url><loc>https://russcult.anibilag.ru/?category=Film</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Theater</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Music</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Sports</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Art</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Legends</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Anniversaries</loc><changefreq>daily</changefreq><priority>0.9</priority></url><url><loc>https://russcult.anibilag.ru/?category=Memory</loc><changefreq>daily</changefreq><priority>0.9</priority></url></urlset>

View File

@ -7,6 +7,7 @@ import { ArticlePage } from './pages/ArticlePage';
import { AdminPage } from './pages/AdminPage';
import { LoginPage } from './pages/LoginPage';
import { UserManagementPage } from './pages/UserManagementPage';
import { AuthorManagementPage } from './pages/AuthorManagementPage';
import { SearchPage } from './pages/SearchPage';
import { BookmarksPage } from './pages/BookmarksPage';
import { Footer } from './components/Footer';
@ -67,6 +68,14 @@ function App() {
</AuthGuard>
}
/>
<Route
path="/admin/authors"
element={
<AuthGuard>
<AuthorManagementPage />
</AuthGuard>
}
/>
<Route
path="/admin/import"
element={

View File

@ -1,7 +1,8 @@
import { Clock, ThumbsUp, MapPin, ThumbsDown } from 'lucide-react';
import { Link, useLocation } from 'react-router-dom';
import { Article, CategoryTitles, CityTitles } from '../types';
import MinutesWord from './MinutesWord';
import MinutesWord from './Words/MinutesWord';
interface ArticleCardProps {
article: Article;

View File

@ -1,11 +1,11 @@
import React, {useEffect, useState} from 'react';
import {TipTapEditor} from './Editor/TipTapEditor';
import {CoverImageUpload} from './ImageUpload/CoverImageUpload';
import {ImageUploader} from './ImageUpload/ImageUploader';
import {GalleryManager} from './GalleryManager';
import {useGallery} from '../hooks/useGallery';
import {ArticleData, Author, CategoryTitles, CityTitles, GalleryImage} from '../types';
import {useAuthStore} from '../stores/authStore';
import React, { useEffect, useState } from 'react';
import { TipTapEditor } from './Editor/TipTapEditor';
import { CoverImageUpload } from './ImageUpload/CoverImageUpload';
import { ImageUploader } from './ImageUpload/ImageUploader';
import { GalleryManager } from './GalleryManager';
import { useGallery } from '../hooks/useGallery';
import { ArticleData, Author, CategoryTitles, CityTitles, GalleryImage } from '../types';
import { useAuthStore } from '../stores/authStore';
import ConfirmModal from './ConfirmModal';

View File

@ -3,7 +3,7 @@ import axios from 'axios';
import { Article } from '../types';
import { CategoryTitles, CityTitles } from '../types';
import { Pencil, Trash2, ChevronLeft, ChevronRight, ImagePlus, ToggleLeft, ToggleRight, Plus } from 'lucide-react';
import MinutesWord from '../components/MinutesWord';
import MinutesWord from './Words/MinutesWord';
import { useAuthStore } from '../stores/authStore';
import { usePermissions } from '../hooks/usePermissions';

View File

@ -0,0 +1,86 @@
import React, { useState, useEffect } from "react";
import { Author, User } from "../types/auth.ts";
interface AuthorModalProps {
open: boolean;
onClose: () => void;
onSave: (author: Partial<Author>) => void;
author?: Author | null;
users: User[];
}
const AuthorModal: React.FC<AuthorModalProps> = ({ open, onClose, onSave, author, users }) => {
const [displayName, setDisplayName] = useState("");
const [bio, setBio] = useState("");
const [userId, setUserId] = useState<string | undefined>(undefined);
useEffect(() => {
if (author) {
setDisplayName(author.displayName || "");
setBio(author.bio || "");
setUserId(author.userId);
} else {
setDisplayName("");
setBio("");
setUserId(undefined);
}
}, [author, open]);
const handleSubmit = () => {
onSave({
id: author?.id,
displayName,
bio,
userId: userId || undefined,
});
onClose();
};
if (!open) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-30 flex items-center justify-center z-50">
<div className="bg-white rounded p-4 w-[400px] shadow-xl">
<h2 className="text-lg font-bold mb-2">{author ? "Редактировать" : "Создать"} автора</h2>
<label className="block text-sm mb-1">Имя</label>
<input
className="w-full border rounded px-2 py-1 mb-3"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<label className="block text-sm mb-1">Биография</label>
<textarea
className="w-full border rounded px-2 py-1 mb-3"
value={bio}
onChange={(e) => setBio(e.target.value)}
/>
<label className="block text-sm mb-1">Пользователь</label>
<select
className="w-full border rounded px-2 py-1 mb-4"
value={userId || ""}
onChange={(e) => setUserId(e.target.value || undefined)}
>
<option value=""> не связан </option>
{users.map((user) => (
<option key={user.id} value={user.id}>
{user.displayName}
</option>
))}
</select>
<div className="flex justify-end gap-2">
<button className="px-3 py-1 border rounded" onClick={onClose}>Отмена</button>
<button className="px-3 py-1 bg-blue-600 text-white rounded" onClick={handleSubmit}>
Сохранить
</button>
</div>
</div>
</div>
);
};
export default AuthorModal;

View File

@ -1,8 +1,11 @@
import { Twitter, Instagram, Globe } from 'lucide-react';
import {Globe, Mail, ThumbsUp} from 'lucide-react';
import {useEffect, useState} from "react";
import axios from "axios";
import { Author } from "../types/auth";
import { Link } from 'react-router-dom';
import { VkIcon } from "../icons/custom/VkIcon";
import { OkIcon } from "../icons/custom/OkIcon";
import ArticlesWord from './Words/ArticlesWord';
export function AuthorsSection() {
@ -13,6 +16,7 @@ export function AuthorsSection() {
const fetchAuthors = async () => {
try {
const response = await axios.get('/api/authors/');
console.log(response);
setAuthors(response.data);
} catch (error) {
console.error('Ошибка загрузки авторов:', error);
@ -32,7 +36,9 @@ export function AuthorsSection() {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{authors.map((author) => (
{authors
.filter(a => a.isActive)
.map((author) => (
<div key={author.id} className="bg-white rounded-lg shadow-md overflow-hidden transition-transform hover:scale-[1.02] h-full">
<div className="p-6 flex flex-col flex-grow min-h-[200px] h-full">
<div className="flex items-center mb-4">
@ -44,15 +50,46 @@ export function AuthorsSection() {
<div>
<h3 className="text-xl font-bold text-gray-900">{author.displayName}</h3>
<div className="flex mt-2 space-x-2">
<a href="#" className="text-gray-400 hover:text-blue-500 transition-colors">
<Twitter size={18} />
</a>
<a href="#" className="text-gray-400 hover:text-pink-500 transition-colors">
<Instagram size={18} />
</a>
<a href="#" className="text-gray-400 hover:text-gray-700 transition-colors">
<Globe size={18} />
</a>
{author.okUrl && (
<a
href={author.okUrl}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-blue-500 transition-colors"
>
<OkIcon size={18} />
</a>
)}
{author.vkUrl && (
<a
href={author.vkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-pink-500 transition-colors"
>
<VkIcon size={18} />
</a>
)}
{author.websiteUrl && (
<a
href={author.websiteUrl}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-gray-700 transition-colors"
>
<Globe size={18} />
</a>
)}
{author.email && (
<a
href={author.email}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-gray-700 transition-colors"
>
<Mail size={18} />
</a>
)}
</div>
</div>
</div>
@ -60,9 +97,15 @@ export function AuthorsSection() {
<div className="mt-auto pt-6 border-t border-gray-100">
<div className="flex justify-between items-center">
<span className="text-sm text-gray-500">
{author.articlesCount} статей
</span>
<div className="flex items-center text-sm text-gray-500 space-x-2">
<span> {author.articlesCount} <ArticlesWord articles={author.articlesCount} /> · </span>
<div className="flex items-center">
<ThumbsUp size={16} className="mr-1" />
<span>{author.totalLikes}</span>
</div>
</div>
<Link
to={`/search?author=${author.id}`}
className="text-blue-600 hover:text-blue-800 text-sm font-medium"

View File

@ -32,7 +32,7 @@ export function BookmarkButton({ article, className = '' }: BookmarkButtonProps)
}`}
/>
<span className="absolute -bottom-8 left-1/2 transform -translate-x-1/2 px-2 py-1 bg-gray-800 text-white text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap">
{bookmarked ? 'Remove bookmark' : 'Add bookmark'}
{bookmarked ? 'Убрать закладку' : 'Добавить закладку'}
</span>
</button>
);

View File

@ -1,6 +1,7 @@
import { Link } from 'react-router-dom';
import { Mail, Phone, Instagram, Twitter, Facebook, ExternalLink } from 'lucide-react';
import { Mail, Phone, Instagram, Facebook, ExternalLink } from 'lucide-react';
import { DesignStudioLogo } from './DesignStudioLogo';
import { VkIcon } from "../../icons/custom/VkIcon1";
export function Footer() {
return (
@ -17,7 +18,7 @@ export function Footer() {
</p>
<div className="flex space-x-4">
<a href="https://twitter.com" className="hover:text-white transition-colors">
<img src="/images/ok-11.svg" alt="" width="20" height="20"/>
<VkIcon className="w-6 h-6"/>
</a>
<a href="https://facebook.com" className="hover:text-white transition-colors">
<Facebook size={20}/>

View File

@ -0,0 +1,22 @@
import React from 'react';
// Описание типа для пропсов компонента
interface ArticlesWordProps {
articles: number; // Количество статей
}
const ArticlesWord: React.FC<ArticlesWordProps> = ({ articles }) => {
const getArticleWord = (articles: number): string => {
if (articles === 1) {
return "статья";
}
if (articles >= 2 && articles <= 4) {
return "статьи";
}
return "статей";
};
return <>{getArticleWord(articles)}</>;
};
export default ArticlesWord;

View File

@ -0,0 +1,140 @@
import { useState, useEffect } from 'react';
import {Author, AuthorFormData, User} from '../types/auth';
import { authorService } from '../services/authorService';
import { userService } from "../services/userService";
export function useAuthorManagement() {
const [authors, setAuthors] = useState<Author[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [selectedAuthor, setSelectedAuthor] = useState<Author | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchAuthors();
fetchUsers();
}, []);
const fetchAuthors = async () => {
try {
setLoading(true);
const fetchedAuthors = await authorService.getAuthors();
setAuthors(fetchedAuthors);
setError(null);
} catch (err) {
setError('Ошибка получения списка авторов');
console.error('Ошибка получения списка авторов:', err);
} finally {
setLoading(false);
}
};
const fetchUsers = async () => {
try {
setLoading(true);
const fetchedUsers = await userService.getUsers();
setUsers(fetchedUsers);
setError(null);
} catch (err) {
setError('Ошибка получения списка авторов');
console.error('Ошибка получения списка авторов:', err);
} finally {
setLoading(false);
}
};
const createAuthor = async (formData: AuthorFormData) => {
try {
const newAuthor = await authorService.createAuthor(formData);
setAuthors([...authors, newAuthor]);
setError(null);
} catch (err) {
setError('Ошибка создания автора');
throw err;
}
};
const updateAuthor = async (authorId: string, formData: AuthorFormData) => {
try {
const updatedAuthor = await authorService.updateAuthor(authorId, formData);
setAuthors(authors.map(author => author.id === authorId ? updatedAuthor : author));
setError(null);
} catch (err) {
setError('Ошибка редакторования данных автора');
throw err;
}
};
const linkUser = async (authorId: string, userId: string) => {
try {
await authorService.linkUser(authorId, userId);
setError(null);
} catch (err) {
setError('Ошибка связывания пользователя с автором');
throw err;
}
};
const unlinkUser = async (authorId: string) => {
try {
await authorService.unlinkUser(authorId);
setError(null);
} catch (err) {
setError('Ошибка отвязывания пользователя от автора');
throw err;
}
};
const orderMoveUp = async (authorId: string) => {
await authorService.reorderAuthor(authorId, 'up');
};
const orderMoveDown = async (authorId: string) => {
await authorService.reorderAuthor(authorId, 'down');
};
const toggleActive = async (authorId: string, isActive: boolean) => {
try {
console.log(isActive);
await authorService.toggleActive(authorId, isActive);
setError(null);
} catch (err) {
setError('Ошибка отвязывания пользователя от автора');
throw err;
}
};
const deleteAuthor = async (authorId: string) => {
try {
await authorService.deleteAuthor(authorId);
setAuthors(authors.filter(author => author.id !== authorId));
if (selectedAuthor?.id === authorId) {
setSelectedAuthor(null);
}
setError(null);
} catch (err) {
setError('Ошибка удаления автора');
throw err;
}
};
return {
authors,
users,
selectedAuthor,
loading,
error,
setSelectedAuthor,
createAuthor,
updateAuthor,
linkUser,
unlinkUser,
orderMoveUp,
orderMoveDown,
toggleActive,
deleteAuthor,
fetchAuthors,
fetchUsers,
};
}

View File

@ -0,0 +1,18 @@
import { LucideProps } from "lucide-react";
export const OkIcon = (props: LucideProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M9.53 11.996c-.77-.081-1.463-.272-2.057-.74-.074-.058-.15-.114-.217-.18-.26-.25-.287-.539-.08-.835.175-.254.47-.322.778-.177a1.2 1.2 0 0 1 .17.102c1.107.766 2.628.787 3.74.034.11-.085.227-.154.363-.19a.57.57 0 0 1 .655.264c.163.267.16.527-.04.734a3.018 3.018 0 0 1-1.087.708c-.388.152-.814.228-1.235.279.064.07.094.104.134.144.571.578 1.145 1.154 1.715 1.734.194.198.235.443.128.673-.117.25-.379.416-.635.398a.617.617 0 0 1-.402-.207c-.431-.437-.87-.866-1.293-1.311-.123-.13-.183-.105-.291.007-.434.45-.875.893-1.319 1.334-.199.197-.436.233-.667.12a.68.68 0 0 1-.39-.626c.009-.171.092-.303.21-.42l1.694-1.709c.037-.038.072-.078.126-.136z" />
<path d="M9.988 10a2.503 2.503 0 0 1-2.481-2.506A2.506 2.506 0 0 1 10.018 5a2.503 2.503 0 0 1 2.489 2.532C12.5 8.898 11.37 10.005 9.988 10zm1.244-2.502a1.218 1.218 0 0 0-1.224-1.221 1.22 1.22 0 0 0-1.226 1.235 1.218 1.218 0 0 0 1.233 1.212 1.216 1.216 0 0 0 1.217-1.226z" />
<path d="M4 2a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H4zm0-2h12a4 4 0 0 1 4 4v12a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4z" />
</svg>
);

View File

@ -0,0 +1,16 @@
import { LucideProps } from "lucide-react";
export const VkIcon = (props: LucideProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512" // адаптировано к стандартному квадрату
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M306 343 L302 340 L278 314 L275 312 L274 316 L274 337 L273 338 L264 338 L247 338 L246 336 L246 256 L244 254 L235 254 L220 254 L219 256 Q218 276 227 296 Q236 316 256 331 L256 336 L230 336 Q200 306 192 254 L189 254 L170 254 Q171 269 179 291 Q187 313 208 337 L208 339 L178 339 Q147 306 139 254 L138 254 L121 254 Q120 295 139 324 Q157 354 191 369 Q225 384 275 382 Q306 380 328 368 Q351 355 360 337 L360 336 L345 312 Q342 308 340 308 Q336 308 329 315 Q320 324 313 332 Q310 335 306 343 Z" />
</svg>
);

View File

@ -0,0 +1,17 @@
import { LucideProps } from "lucide-react";
export const VkIcon = (props: LucideProps) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<path d="M3 5.75c0-.966.784-1.75 1.75-1.75h14.5c.966 0 1.75.784 1.75 1.75v12.5c0 .966-.784 1.75-1.75 1.75H4.75A1.75 1.75 0 0 1 3 18.25V5.75Z" />
<path d="M6.5 8c.2 3.26 2.2 5.75 5.5 5.75h.25v2c1.75 0 3-1.5 3.75-2.5h1.25c.25 0 .5-.25.5-.5v-1.5c0-.25-.25-.5-.5-.5H16c-.75-1-1.5-2.5-1.5-3.75V7c0-.25-.25-.5-.5-.5h-1.5c-.25 0-.5.25-.5.5v.5c0 .75.25 1.5.5 2H12c-.25 0-.75-.75-.75-2.5V7c0-.25-.25-.5-.5-.5H9.5c-.25 0-.5.25-.5.5V9c0 .75.25 1.5.75 2H9c-.5 0-1.5-1.5-2-3H6.5Z" />
</svg>
);

View File

@ -7,6 +7,8 @@ import { GalleryModal } from '../components/GalleryManager/GalleryModal';
import { ArticleDeleteModal } from '../components/ArticleDeleteModal';
import { Article, Author, GalleryImage, ArticleData } from '../types';
import { usePermissions } from '../hooks/usePermissions';
import { FileJson, Users, UserSquare2 } from "lucide-react";
import { Link } from 'react-router-dom';
interface FormState {
title: string;
@ -38,7 +40,7 @@ export function AdminPage() {
useEffect(() => {
const fetchAuthors = async () => {
try {
const response = await axios.get('/api/users/', {
const response = await axios.get('/api/authors/', {
headers: {
Authorization: `Bearer ${localStorage.getItem('token')}`,
},
@ -171,6 +173,33 @@ export function AdminPage() {
<div className="min-h-screen bg-gray-50">
<Header />
<main className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Admin Navigation */}
{isAdmin && (
<div className="flex gap-4 mb-8">
<Link
to="/admin/users"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
>
<Users className="h-5 w-5 mr-2" />
Пользователи
</Link>
<Link
to="/admin/authors"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
>
<UserSquare2 className="h-5 w-5 mr-2" />
Авторы
</Link>
<Link
to="/admin/import"
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
>
<FileJson className="h-5 w-5 mr-2" />
Импорт статей
</Link>
</div>
)}
{error && isAdmin && (
<div className="mb-6 bg-red-50 text-red-700 p-4 rounded-md">{error}</div>
)}

View File

@ -9,7 +9,7 @@ import { SEO } from '../components/SEO';
import { ArticleContent } from '../components/ArticleContent';
import { ShareButton } from '../components/ShareButton';
import { BookmarkButton } from '../components/BookmarkButton';
import MinutesWord from '../components/MinutesWord';
import MinutesWord from '../components/Words/MinutesWord';
import axios from "axios";
import api from "../utils/api";
@ -178,6 +178,16 @@ export function ArticlePage() {
/>
</div>
</article>
<div className="pt-8"/>
<Link
to={backTo}
className="inline-flex items-center text-gray-600 hover:text-gray-900 mb-8"
>
<ArrowLeft size={20} className="mr-2" />
К списку статей
</Link>
</main>
</div>
);

View File

@ -0,0 +1,583 @@
import React, { useState } from 'react';
import { Header } from '../components/Header';
import { AuthGuard } from '../components/AuthGuard';
import { AuthorFormData } from '../types/auth';
import { useAuthorManagement } from '../hooks/useAuthorManagement';
import {
ImagePlus,
X,
UserPlus,
Pencil,
Link as LinkIcon,
Link2Off as LinkOff,
ToggleLeft,
ToggleRight,
Trash2, ChevronDown, ChevronUp
} from 'lucide-react';
import { imageResolutions } from '../config/imageResolutions';
import { useAuthStore } from '../stores/authStore';
import axios from "axios";
const initialFormData: AuthorFormData = {
id: '',
displayName: '',
email: '',
bio: '',
avatarUrl: '/images/avatar.jpg',
order: 0,
okUrl: '',
vkUrl: '',
websiteUrl: '',
articlesCount: 0,
isActive: true
};
export function AuthorManagementPage() {
const {
authors,
users,
selectedAuthor,
loading,
error,
setSelectedAuthor,
createAuthor,
updateAuthor,
linkUser,
unlinkUser,
orderMoveUp,
orderMoveDown,
toggleActive,
deleteAuthor,
fetchAuthors,
fetchUsers,
} = useAuthorManagement();
const { user } = useAuthStore();
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showLinkUserModal, setShowLinkUserModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState<string | null>(null);
const [formData, setFormData] = useState<AuthorFormData>(initialFormData);
const [formError, setFormError] = useState<string | null>(null);
const [selectedUserId, setSelectedUserId] = useState<string>('');
const handleAvatarUpload = async (event: React.ChangeEvent<HTMLInputElement>, authorId: string) => {
const file = event.target.files?.[0];
if (!file) return;
try {
const resolution = imageResolutions.find(r => r.id === 'thumbnail');
if (!resolution) throw new Error('Invalid resolution');
const formData = new FormData();
formData.append('file', file);
formData.append('resolutionId', resolution.id);
formData.append('folder', 'authors/' + authorId);
const response = await axios.post('/api/images/upload-url', formData, {
headers: {
'Content-Type': 'multipart/form-data',
'Authorization': `Bearer ${localStorage.getItem('token')}`, // Передача токена аутентификации
},
});
setFormData(prev => ({ ...prev, avatarUrl: response.data?.fileUrl }));
} catch (error) {
setFormError('Ошибка загрузки аватара. Повторите попытку.');
console.error('Ошибка загрузки:', error);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError(null);
try {
if (showCreateModal) {
await createAuthor(formData);
setShowCreateModal(false);
} else if (showEditModal && selectedAuthor) {
await updateAuthor(selectedAuthor.id, formData);
setShowEditModal(false);
await fetchAuthors();
}
setFormData(initialFormData);
} catch (error) {
setFormError(error instanceof Error ? error.message : 'An error occurred');
}
};
const handleLinkUser = async (authorId: string, userId: string) => {
if (!selectedAuthor || !selectedUserId) return;
try {
await linkUser(authorId, userId);
setShowLinkUserModal(false);
setSelectedUserId('');
await fetchAuthors();
await fetchUsers();
} catch {
setFormError('Failed to link user to author');
}
};
const handleUnlinkUser = async (authorId: string) => {
try {
await unlinkUser(authorId);
await fetchAuthors();
await fetchUsers();
} catch {
setFormError('Failed to unlink user from author');
}
};
const handleMoveUp = async (authorId: string) => {
await orderMoveUp(authorId);
fetchAuthors();
};
const handleMoveDown = async (authorId: string) => {
await orderMoveDown(authorId);
fetchAuthors();
};
const handleToggleActive = async (authorId: string, isActive: boolean) => {
try {
await toggleActive(authorId, isActive);
await fetchAuthors();
} catch {
setFormError('Failed to toggle author status');
}
};
const handleDeleteAuthor = async (authorId: string) => {
try {
await deleteAuthor(authorId);
setShowDeleteModal(null)
} catch {
setFormError('Failed to delete author');
}
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
</div>
);
}
return (
<AuthGuard>
<div className="min-h-screen bg-gray-50">
<Header />
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
<div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-gray-900">
Управление авторами
</h1>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
>
<UserPlus className="h-5 w-5 mr-2" />
Добавить автора
</button>
</div>
{error && (
<div className="mb-8 bg-red-50 text-red-700 p-4 rounded-md">
{error}
</div>
)}
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<ul className="divide-y divide-gray-200">
{authors.map((author, index) => (
<li key={author.id} className="px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<img
src={author.avatarUrl || '/images/avatar.jpg'}
alt={author.displayName}
className="h-10 w-10 rounded-full"
/>
<div className="ml-4">
<h2 className="text-lg font-medium text-gray-900">
{author.displayName} {author.userId ? `=> пользователь ${author.displayName}` : ``}
</h2>
<p className="text-sm text-gray-500">{author.bio}</p>
<div className="flex mt-1 space-x-4">
{author.okUrl && (
<a
href={author.okUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-gray-500 hover:text-blue-500"
>
Одноклассники
</a>
)}
{author.vkUrl && (
<a
href={author.vkUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-gray-500 hover:text-pink-500"
>
Вконтакте
</a>
)}
{author.websiteUrl && (
<a
href={author.websiteUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-gray-500 hover:text-gray-700"
>
Website
</a>
)}
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex flex-col items-center space-y-1">
<button
onClick={() => handleMoveUp(author.id)}
disabled={index === 0}
className="p-1 text-gray-400 hover:text-blue-500 disabled:opacity-30"
title="Переместить вверх"
>
<ChevronUp size={18} />
</button>
<button
onClick={() => handleMoveDown(author.id)}
disabled={index === authors.length - 1}
className="p-1 text-gray-400 hover:text-blue-500 disabled:opacity-30"
title="Переместить вниз"
>
<ChevronDown size={18} />
</button>
</div>
<button
onClick={() => handleToggleActive(author.id, !author.isActive)}
className={`p-2 rounded-full hover:bg-gray-100 ${
author.isActive ? 'text-green-600' : 'text-gray-400'
}`}
title={author.isActive ? 'Деактивировать автора' : 'Активировать автора'}
>
{author.isActive ? <ToggleRight size={20} /> : <ToggleLeft size={20} />}
</button>
{author.userId ? (
<button
onClick={() => handleUnlinkUser(author.id)}
className="p-2 rounded-full hover:bg-gray-100 text-gray-400"
>
<LinkOff size={20} />
</button>
) : (
<button
onClick={() => {
setSelectedAuthor(author);
setShowLinkUserModal(true);
}}
className="p-2 rounded-full hover:bg-gray-100 text-gray-400"
>
<LinkIcon size={20} />
</button>
)}
<button
onClick={() => {
setSelectedAuthor(author);
setFormData({
id: author.id,
displayName: author.displayName,
email: author.email || '',
bio: author.bio || '',
avatarUrl: author.avatarUrl || '',
order: author.order,
okUrl: author.okUrl || '',
vkUrl: author.vkUrl || '',
websiteUrl: author.websiteUrl || '',
articlesCount: author.articlesCount,
isActive: author.isActive || true
});
setShowEditModal(true);
}}
className="p-2 rounded-full hover:bg-gray-100 text-gray-400"
>
<Pencil size={20} />
</button>
<button
onClick={() => setShowDeleteModal(author.id)}
className="p-2 rounded-full hover:bg-gray-100 text-red-400"
>
<Trash2 size={20} />
</button>
</div>
</div>
</li>
))}
</ul>
</div>
</div>
</main>
{/* Create/Edit Author Modal */}
{((showCreateModal || showEditModal) && user?.permissions.isAdmin ) && (
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center p-4">
<div className="bg-white rounded-lg max-w-md w-full p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">
{showCreateModal ? 'Новый автор' : 'Изменить автора'}
</h3>
<button
onClick={() => {
setShowCreateModal(false);
setShowEditModal(false);
setFormData(initialFormData);
setFormError(null);
}}
className="text-gray-400 hover:text-gray-500"
>
<X size={20} />
</button>
</div>
{formError && (
<div className="mb-8 bg-red-50 text-red-700 p-4 rounded-md">
{formError}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
Аватар
</label>
<div className="mt-1 flex items-center space-x-4">
<img
src={formData.avatarUrl || 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&q=80&w=150&h=150'}
alt="Author avatar"
className="h-12 w-12 rounded-full object-cover"
/>
<label className="cursor-pointer inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50">
<ImagePlus size={16} className="mr-2" />
Сменить Аватар
<input
type="file"
className="hidden"
accept="image/*"
onChange={(e) => handleAvatarUpload(e, formData.id)} // Передаем authorId
/>
</label>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Имя</span> <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.displayName}
onChange={(e) => setFormData(prev => ({ ...prev, displayName: e.target.value }))}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Биографические данные</span>
</label>
<textarea
value={formData.bio}
onChange={(e) => setFormData(prev => ({ ...prev, bio: e.target.value }))}
rows={3}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Одноклассники URL</span>
</label>
<input
type="url"
value={formData.okUrl}
onChange={(e) => setFormData(prev => ({ ...prev, okUrl: e.target.value }))}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
placeholder="https://twitter.com/username"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">E-mail</span>
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
placeholder="author@email.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Вконтакте URL</span>
</label>
<input
type="url"
value={formData.vkUrl}
onChange={(e) => setFormData(prev => ({ ...prev, vkUrl: e.target.value }))}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
placeholder="https://instagram.com/username"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
<span className="italic font-bold">Сайт URL</span>
</label>
<input
type="url"
value={formData.websiteUrl}
onChange={(e) => setFormData(prev => ({ ...prev, websiteUrl: e.target.value }))}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
placeholder="https://example.com"
/>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
type="button"
onClick={() => {
setShowCreateModal(false);
setShowEditModal(false);
setFormData(initialFormData);
setFormError(null);
}}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
Отмена
</button>
<button
type="submit"
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
>
{showCreateModal ? 'Создать' : 'Сохранить'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Link User Modal */}
{showLinkUserModal && (
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center p-4">
<div className="bg-white rounded-lg max-w-md w-full p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">
Связать автора с пользователем
</h3>
<button
onClick={() => {
setShowLinkUserModal(false);
setSelectedUserId('');
}}
className="text-gray-400 hover:text-gray-500"
>
<X size={20} />
</button>
</div>
<div className="space-y-4">
<div>
<select
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
>
<option value="">Выберите пользователя...</option>
{users
.filter(u => !u.isLinkedToAuthor) // Only show users not already linked to an author
.map(user => (
<option key={user.id} value={user.id}>
{user.displayName})
</option>
))
}
</select>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
type="button"
onClick={() => {
setShowLinkUserModal(false);
setSelectedUserId('');
}}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
Отмена
</button>
<button
type="button"
onClick={() => {
if (selectedAuthor && selectedUserId) {
handleLinkUser(selectedAuthor.id, selectedUserId);
}
}}
disabled={!selectedUserId}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Связать
</button>
</div>
</div>
</div>
</div>
)}
{/* Delete Confirmation Modal */}
{showDeleteModal && (
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 flex items-center justify-center p-4">
<div className="bg-white rounded-lg max-w-md w-full p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">
Удалить автора
</h3>
<p className="text-sm text-gray-500 mb-6">
Вы уверены, что хотите удалить этого автора? Это действие невозможно отменить.
Все статьи, связанные с этим автором, останутся, но у них больше не будет активного автора.
</p>
<div className="flex justify-end gap-4">
<button
onClick={() => setShowDeleteModal(null)}
className="px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
Отмена
</button>
<button
onClick={() => handleDeleteAuthor(showDeleteModal)}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-red-600 hover:bg-red-700"
>
Удалить
</button>
</div>
</div>
</div>
)}
</div>
</AuthGuard>
);
}

View File

@ -1,4 +1,4 @@
import React, { useState, useRef, useCallback } from 'react';
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { Header } from '../components/Header';
import { AuthGuard } from '../components/AuthGuard';
import { Article, CategoryTitles, CityTitles } from '../types';
@ -9,6 +9,11 @@ const ARTICLES_PER_PAGE = 10;
export function ImportArticlesPage() {
const [articles, setArticles] = useState<Article[]>([]);
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
window.scrollTo({ top: 0, behavior: 'smooth' });
}, [currentPage]);
const [editingArticle, setEditingArticle] = useState<Article | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -31,7 +36,14 @@ export function ImportArticlesPage() {
const jsonData = JSON.parse(text as string);
if (Array.isArray(jsonData)) {
setArticles(jsonData);
const normalized = jsonData.map((article: Article) => ({
...article,
id: article.importId.toString(), // 👈 теперь будет использоваться корректный id
images: Array.isArray(article.images) ? [...article.images] : [],
imageSubs: Array.isArray(article.imageSubs) ? [...article.imageSubs] : [],
}));
setArticles(normalized);
setCurrentPage(1);
setError(null);
} else {
@ -44,10 +56,21 @@ export function ImportArticlesPage() {
reader.readAsText(file);
};
const handleEditField = <K extends keyof Article>(articleId: string, field: K, value: Article[K]) => {
setArticles(prev => prev.map(article =>
article.id === articleId ? { ...article, [field]: value } : article
));
const handleEditField = <K extends keyof Article>(
articleId: string,
field: K,
value: Article[K]
) => {
setArticles(prev =>
prev.map(article => {
if (article.id !== articleId) return article;
return {
...article,
[field]: Array.isArray(value) ? [...value] : value, // гарантированно новая ссылка
};
})
);
};
const handleSaveToBackend = useCallback(async () => {
@ -78,6 +101,22 @@ export function ImportArticlesPage() {
}
}, [articles]);
const handleImageSubEdit = (articleId: string, index: number, newValue: string) => {
setArticles(prev =>
prev.map(article => {
if (article.id !== articleId) return article;
const updatedSubs = article.imageSubs ? [...article.imageSubs] : [];
updatedSubs[index] = newValue;
return {
...article,
imageSubs: updatedSubs, // новая ссылка!
};
})
);
};
return (
<AuthGuard>
<div className="min-h-screen bg-gray-50">
@ -152,6 +191,19 @@ export function ImportArticlesPage() {
{CategoryTitles[article.categoryId]} · {CityTitles[article.cityId]} · {article.readTime} min read
</div>
<div className="mt-2">
<label className="block text-sm font-medium text-gray-700 mb-1">Автор:</label>
<input
type="text"
value={article.authorName}
onChange={(e) =>
handleEditField(article.id, 'authorName', e.target.value)
}
className="w-full border border-gray-300 rounded px-3 py-1 text-sm"
placeholder="Введите имя автора"
/>
</div>
{(article.images?.length || 0) > 0 && (
<div className="mt-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Изображения с подписями:</h4>
@ -166,11 +218,7 @@ export function ImportArticlesPage() {
<input
type="text"
value={article.imageSubs?.[index] || ''}
onChange={(e) => {
const newSubs = [...(article.imageSubs || [])];
newSubs[index] = e.target.value;
handleEditField(article.id, 'imageSubs', newSubs);
}}
onChange={(e) => handleImageSubEdit(article.id, index, e.target.value)}
placeholder={`Подпись ${index + 1}`}
className="w-full border border-gray-300 rounded px-2 py-1 text-sm"
/>

View File

@ -8,6 +8,7 @@ import { imageResolutions } from '../config/imageResolutions';
import { CategoryIds, CategoryTitles, CityIds, CityTitles } from "../types";
import axios from "axios";
import { useAuthStore } from "../stores/authStore";
//import { AuthorModal } from "./AuthorModal";
const initialFormData: UserFormData = {
@ -15,7 +16,6 @@ const initialFormData: UserFormData = {
email: '',
password: '',
displayName: '',
bio: '',
avatarUrl: '/images/avatar.jpg'
};
@ -86,8 +86,6 @@ export function UserManagementPage() {
}
};
const isAttributionOnly = formData.attributionOnly || (!selectedUser?.email && !selectedUser?.permissions.isAdmin);
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
@ -145,7 +143,7 @@ export function UserManagementPage() {
/>
<div className="flex-1">
<div className="font-medium">{user.displayName}</div>
<div className="text-sm text-gray-500">{user.email || 'Без входа'}</div>
<div className="text-sm text-gray-500">{user.email}</div>
</div>
<button
onClick={() => {
@ -155,9 +153,7 @@ export function UserManagementPage() {
email: user.email,
password: '',
displayName: user.displayName,
bio: user.bio,
avatarUrl: user.avatarUrl,
attributionOnly: !user.email
avatarUrl: user.avatarUrl
});
setShowEditModal(true);
}}
@ -172,7 +168,7 @@ export function UserManagementPage() {
</div>
{/* Permissions Editor */}
{selectedUser && selectedUser.email && (
{selectedUser && (
<div className="md:col-span-2 bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-medium text-gray-900 mb-4">
Редактирование прав пользователя "{selectedUser.displayName}"
@ -310,66 +306,37 @@ export function UserManagementPage() {
value={formData.displayName}
onChange={(e) => setFormData(prev => ({ ...prev, displayName: e.target.value }))}
autoComplete="off"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
required
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="attributionOnly"
checked={formData.attributionOnly}
onChange={(e) => setFormData(prev => ({ ...prev, attributionOnly: e.target.checked }))}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="attributionOnly" className="ml-2 block text-sm text-gray-900">
Без возможности входа
<div>
<label className="block text-sm font-medium text-gray-700">
E-mail
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
autoComplete="off"
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
required
/>
</div>
{!isAttributionOnly && (
<>
<div>
<label className="block text-sm font-medium text-gray-700">
E-mail
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
autoComplete="off"
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
required={!isAttributionOnly}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">
Пароль
</label>
<input
type="password"
value={formData.password}
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
autoComplete="new-password"
placeholder={showEditModal ? 'Оставьте пустым, чтобы не менять' : ''}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
required={!isAttributionOnly && !showEditModal}
/>
</div>
</>
)}
<div>
<label className="block text-sm font-medium text-gray-700">
Биографические данные
Пароль
</label>
<textarea
value={formData.bio}
onChange={(e) => setFormData(prev => ({ ...prev, bio: e.target.value }))}
rows={4}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
<input
type="password"
value={formData.password}
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
autoComplete="new-password"
placeholder={showEditModal ? 'Оставьте пустым, чтобы не менять' : ''}
className="px-2 py-1 mt-1 block w-full rounded-lg border border-gray-300 shadow-sm hover:border-blue-300 focus:border-blue-500 focus:ring-4 focus:ring-blue-100 transition-all duration-200"
required={!showEditModal}
/>
</div>

View File

@ -0,0 +1,87 @@
import axios from '../utils/api';
import { Author } from '../types/auth';
interface AuthorFormData {
displayName: string;
bio?: string;
avatarUrl?: string;
email?: string;
order?: number;
twitterUrl?: string;
instagramUrl?: string;
websiteUrl?: string;
isActive?: boolean;
}
export const authorService = {
getAuthors: async (): Promise<Author[]> => {
try {
const response = await axios.get('/authors');
return response.data;
} catch (error) {
console.error('Ошибка получения списка авторов:', error);
throw new Error('Невозможно получить список авторов');
}
},
createAuthor: async (authorData: AuthorFormData): Promise<Author> => {
try {
const response = await axios.post('/authors', authorData);
return response.data;
} catch (error) {
console.error('Ошибка создания автора:', error);
throw new Error('Невозможно создать автора');
}
},
updateAuthor: async (authorId: string, authorData: Partial<AuthorFormData>): Promise<Author> => {
try {
const response = await axios.put(`/authors/${authorId}`, authorData);
return response.data;
} catch (error) {
console.error('Ошибка изменения данных автора:', error);
throw new Error('Невозможно изменить данные автора');
}
},
deleteAuthor: async (authorId: string): Promise<void> => {
try {
await axios.delete(`/authors/${authorId}`);
} catch (error) {
console.error('Error deleting author:', error);
throw new Error('Failed to delete author');
}
},
reorderAuthor: async (authorId: string, direction: 'up' | 'down') => {
await axios.put(`/authors/${authorId}/reorder`, { direction });
},
toggleActive: async (authorId: string, isActive: boolean): Promise<Author> => {
try {
const response = await axios.put(`/authors/${authorId}/toggle-active`, { isActive });
return response.data;
} catch (error) {
console.error('Error toggling author status:', error);
throw new Error('Failed to toggle author status');
}
},
linkUser: async (authorId: string, userId: string): Promise<void> => {
try {
await axios.put(`/authors/${authorId}/link-user`, { userId });
} catch (error) {
console.error('Error linking user to author:', error);
throw new Error('Failed to link user to author');
}
},
unlinkUser: async (authorId: string): Promise<void> => {
try {
await axios.put(`/authors/${authorId}/unlink-user`);
} catch (error) {
console.error('Error unlinking user from author:', error);
throw new Error('Failed to unlink user from author');
}
}
};

View File

@ -15,6 +15,7 @@ export interface User {
avatarUrl: string;
bio: string;
permissions: UserPermissions;
isLinkedToAuthor: boolean;
}
export interface Author {
@ -23,7 +24,14 @@ export interface Author {
displayName: string;
avatarUrl: string;
bio: string;
order: number;
okUrl: string;
vkUrl: string;
websiteUrl: string;
articlesCount: number;
totalLikes: number;
userId?: string;
isActive?: boolean;
}
export interface UserFormData {
@ -31,7 +39,22 @@ export interface UserFormData {
email?: string;
password?: string;
displayName: string;
avatarUrl: string;
}
export interface AuthorFormData {
id: string;
displayName: string;
email: string;
bio: string;
avatarUrl: string;
attributionOnly?: boolean;
order: number;
okUrl: string;
vkUrl: string;
websiteUrl: string;
articlesCount: number;
totalLikes: number;
userId?: string;
isActive: boolean;
}

View File

@ -7,6 +7,7 @@ export interface Article {
categoryId: number;
cityId: number;
author: Author;
authorName: string;
coverImage: string;
images?: string[];
imageSubs?: string[];
@ -57,7 +58,14 @@ export interface Author {
id: string;
displayName: string;
avatarUrl: string;
email: string;
email?: string;
twitterUrl?: string;
instagramUrl?: string;
websiteUrl?: string;
order: number;
createdAt: string;
updatedAt: string;
isActive: boolean;
}
export const CategoryIds: number[] = [1 , 2 , 3 , 4 , 5 , 6 , 7 , 8];