Добавлена поддержка PWA с конфигурацией манифеста, иконок и кэширования. Обновлены стили и структура компонентов для улучшения пользовательского опыта. Реализованы фильтры на странице заметок и улучшена логика отображения. Упрощены стили и адаптивность элементов интерфейса.

This commit is contained in:
Fovway 2025-11-02 22:54:06 +07:00
parent 6013bd1c79
commit f59cd87ede
25 changed files with 5528 additions and 282 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,2 @@
try{self["workbox:window:7.2.0"]&&_()}catch{}function E(n,r){return new Promise(function(t){var i=new MessageChannel;i.port1.onmessage=function(c){t(c.data)},n.postMessage(r,[i.port2])})}function W(n){var r=function(t,i){if(typeof t!="object"||!t)return t;var c=t[Symbol.toPrimitive];if(c!==void 0){var h=c.call(t,i);if(typeof h!="object")return h;throw new TypeError("@@toPrimitive must return a primitive value.")}return String(t)}(n,"string");return typeof r=="symbol"?r:r+""}function k(n,r){for(var t=0;t<r.length;t++){var i=r[t];i.enumerable=i.enumerable||!1,i.configurable=!0,"value"in i&&(i.writable=!0),Object.defineProperty(n,W(i.key),i)}}function P(n,r){return P=Object.setPrototypeOf?Object.setPrototypeOf.bind():function(t,i){return t.__proto__=i,t},P(n,r)}function j(n,r){(r==null||r>n.length)&&(r=n.length);for(var t=0,i=new Array(r);t<r;t++)i[t]=n[t];return i}function L(n,r){var t=typeof Symbol<"u"&&n[Symbol.iterator]||n["@@iterator"];if(t)return(t=t.call(n)).next.bind(t);if(Array.isArray(n)||(t=function(c,h){if(c){if(typeof c=="string")return j(c,h);var l=Object.prototype.toString.call(c).slice(8,-1);return l==="Object"&&c.constructor&&(l=c.constructor.name),l==="Map"||l==="Set"?Array.from(c):l==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(l)?j(c,h):void 0}}(n))||r){t&&(n=t);var i=0;return function(){return i>=n.length?{done:!0}:{done:!1,value:n[i++]}}}throw new TypeError(`Invalid attempt to iterate non-iterable instance.
In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}try{self["workbox:core:7.2.0"]&&_()}catch{}var w=function(){var n=this;this.promise=new Promise(function(r,t){n.resolve=r,n.reject=t})};function b(n,r){var t=location.href;return new URL(n,t).href===new URL(r,t).href}var g=function(n,r){this.type=n,Object.assign(this,r)};function d(n,r,t){return t?r?r(n):n:(n&&n.then||(n=Promise.resolve(n)),r?n.then(r):n)}function O(){}var x={type:"SKIP_WAITING"};function S(n,r){return n&&n.then?n.then(O):Promise.resolve()}var U=function(n){function r(v,u){var e,o;return u===void 0&&(u={}),(e=n.call(this)||this).nn={},e.tn=0,e.rn=new w,e.en=new w,e.on=new w,e.un=0,e.an=new Set,e.cn=function(){var s=e.fn,a=s.installing;e.tn>0||!b(a.scriptURL,e.sn.toString())||performance.now()>e.un+6e4?(e.vn=a,s.removeEventListener("updatefound",e.cn)):(e.hn=a,e.an.add(a),e.rn.resolve(a)),++e.tn,a.addEventListener("statechange",e.ln)},e.ln=function(s){var a=e.fn,f=s.target,p=f.state,m=f===e.vn,y={sw:f,isExternal:m,originalEvent:s};!m&&e.mn&&(y.isUpdate=!0),e.dispatchEvent(new g(p,y)),p==="installed"?e.wn=self.setTimeout(function(){p==="installed"&&a.waiting===f&&e.dispatchEvent(new g("waiting",y))},200):p==="activating"&&(clearTimeout(e.wn),m||e.en.resolve(f))},e.yn=function(s){var a=e.hn,f=a!==navigator.serviceWorker.controller;e.dispatchEvent(new g("controlling",{isExternal:f,originalEvent:s,sw:a,isUpdate:e.mn})),f||e.on.resolve(a)},e.gn=(o=function(s){var a=s.data,f=s.ports,p=s.source;return d(e.getSW(),function(){e.an.has(p)&&e.dispatchEvent(new g("message",{data:a,originalEvent:s,ports:f,sw:p}))})},function(){for(var s=[],a=0;a<arguments.length;a++)s[a]=arguments[a];try{return Promise.resolve(o.apply(this,s))}catch(f){return Promise.reject(f)}}),e.sn=v,e.nn=u,navigator.serviceWorker.addEventListener("message",e.gn),e}var t,i;i=n,(t=r).prototype=Object.create(i.prototype),t.prototype.constructor=t,P(t,i);var c,h,l=r.prototype;return l.register=function(v){var u=(v===void 0?{}:v).immediate,e=u!==void 0&&u;try{var o=this;return d(function(s,a){var f=s();return f&&f.then?f.then(a):a(f)}(function(){if(!e&&document.readyState!=="complete")return S(new Promise(function(s){return window.addEventListener("load",s)}))},function(){return o.mn=!!navigator.serviceWorker.controller,o.dn=o.pn(),d(o.bn(),function(s){o.fn=s,o.dn&&(o.hn=o.dn,o.en.resolve(o.dn),o.on.resolve(o.dn),o.dn.addEventListener("statechange",o.ln,{once:!0}));var a=o.fn.waiting;return a&&b(a.scriptURL,o.sn.toString())&&(o.hn=a,Promise.resolve().then(function(){o.dispatchEvent(new g("waiting",{sw:a,wasWaitingBeforeRegister:!0}))}).then(function(){})),o.hn&&(o.rn.resolve(o.hn),o.an.add(o.hn)),o.fn.addEventListener("updatefound",o.cn),navigator.serviceWorker.addEventListener("controllerchange",o.yn),o.fn})}))}catch(s){return Promise.reject(s)}},l.update=function(){try{return this.fn?d(S(this.fn.update())):d()}catch(v){return Promise.reject(v)}},l.getSW=function(){return this.hn!==void 0?Promise.resolve(this.hn):this.rn.promise},l.messageSW=function(v){try{return d(this.getSW(),function(u){return E(u,v)})}catch(u){return Promise.reject(u)}},l.messageSkipWaiting=function(){this.fn&&this.fn.waiting&&E(this.fn.waiting,x)},l.pn=function(){var v=navigator.serviceWorker.controller;return v&&b(v.scriptURL,this.sn.toString())?v:void 0},l.bn=function(){try{var v=this;return d(function(u,e){try{var o=u()}catch(s){return e(s)}return o&&o.then?o.then(void 0,e):o}(function(){return d(navigator.serviceWorker.register(v.sn,v.nn),function(u){return v.un=performance.now(),u})},function(u){throw u}))}catch(u){return Promise.reject(u)}},c=r,(h=[{key:"active",get:function(){return this.en.promise}},{key:"controlling",get:function(){return this.on.promise}}])&&k(c.prototype,h),Object.defineProperty(c,"prototype",{writable:!1}),c}(function(){function n(){this.Pn=new Map}var r=n.prototype;return r.addEventListener=function(t,i){this.jn(t).add(i)},r.removeEventListener=function(t,i){this.jn(t).delete(i)},r.dispatchEvent=function(t){t.target=this;for(var i,c=L(this.jn(t.type));!(i=c()).done;)(0,i.value)(t)},r.jn=function(t){return this.Pn.has(t)||this.Pn.set(t,new Set),this.Pn.get(t)},n}());export{U as Workbox,g as WorkboxEvent,E as messageSW};

View File

@ -2,9 +2,11 @@
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta
<meta name="theme-color" content="#667eea" /> name="viewport"
<title>NoteJS Backend</title> content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<title>NoteJS - Система заметок</title>
<!-- Предотвращение мерцания темы --> <!-- Предотвращение мерцания темы -->
<script> <script>
@ -20,19 +22,29 @@
// Определяем тему: сохраненная или системная // Определяем тему: сохраненная или системная
const theme = savedTheme || (systemPrefersDark ? "dark" : "light"); const theme = savedTheme || (systemPrefersDark ? "dark" : "light");
// Устанавливаем тему до загрузки CSS // Функция для конвертации hex в RGB
function hexToRgb(hex) {
const cleanHex = hex.replace("#", "");
const r = parseInt(cleanHex.substring(0, 2), 16);
const g = parseInt(cleanHex.substring(2, 4), 16);
const b = parseInt(cleanHex.substring(4, 6), 16);
return `${r}, ${g}, ${b}`;
}
// Получаем и устанавливаем accentColor
const savedAccentColor = localStorage.getItem("accentColor");
const accentColor = savedAccentColor || "#007bff";
// Устанавливаем тему и переменные до загрузки CSS
if (theme === "dark") { if (theme === "dark") {
document.documentElement.setAttribute("data-theme", "dark"); document.documentElement.setAttribute("data-theme", "dark");
} else { } else {
document.documentElement.setAttribute("data-theme", "light"); document.documentElement.setAttribute("data-theme", "light");
} }
// Получаем и устанавливаем accentColor // Устанавливаем CSS переменные для accent цвета
const savedAccentColor = localStorage.getItem("accentColor");
const accentColor = savedAccentColor || "#667eea";
// Устанавливаем CSS переменную для accent цвета
document.documentElement.style.setProperty("--accent-color", accentColor); document.documentElement.style.setProperty("--accent-color", accentColor);
document.documentElement.style.setProperty("--accent-color-rgb", hexToRgb(accentColor));
// Устанавливаем цвет для meta theme-color // Устанавливаем цвет для meta theme-color
const themeColorMeta = document.querySelector('meta[name="theme-color"]'); const themeColorMeta = document.querySelector('meta[name="theme-color"]');
@ -45,133 +57,111 @@
} catch (e) { } catch (e) {
// В случае ошибки устанавливаем светлую тему по умолчанию // В случае ошибки устанавливаем светлую тему по умолчанию
document.documentElement.setAttribute("data-theme", "light"); document.documentElement.setAttribute("data-theme", "light");
document.documentElement.style.setProperty("--accent-color", "#667eea"); document.documentElement.style.setProperty("--accent-color", "#007bff");
document.documentElement.style.setProperty("--accent-color-rgb", "0, 123, 255");
} }
})(); })();
</script> </script>
<!-- Критические стили темы для предотвращения flash эффекта -->
<style> <style>
* { :root {
margin: 0; --accent-color: #007bff;
padding: 0;
box-sizing: border-box; /* Светлая тема (по умолчанию) */
--bg-primary: #f5f5f5;
--text-primary: #333333;
--border-primary: #e0e0e0;
--shadow-light: rgba(0, 0, 0, 0.1);
} }
/* Темная тема */
[data-theme="dark"] {
--bg-primary: #1a1a1a;
--text-primary: #ffffff;
--border-primary: #404040;
--shadow-light: rgba(0, 0, 0, 0.3);
}
/* Применяем стили сразу к body для предотвращения flash */
body { body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, background-color: var(--bg-primary);
Oxygen, Ubuntu, Cantarell, sans-serif; color: var(--text-primary);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); transition: background-color 0.3s ease, color 0.3s ease;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
.container {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 40px;
max-width: 600px;
text-align: center;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
}
h1 {
font-size: 2.5em;
margin-bottom: 20px;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
}
.status {
display: inline-block;
background: rgba(76, 175, 80, 0.3);
padding: 10px 20px;
border-radius: 25px;
margin: 20px 0;
font-size: 1.1em;
border: 2px solid rgba(76, 175, 80, 0.6);
}
.info {
margin: 30px 0;
font-size: 1.1em;
line-height: 1.8;
}
.button {
display: inline-block;
background: rgba(255, 255, 255, 0.9);
color: #667eea;
padding: 15px 40px;
border-radius: 30px;
text-decoration: none;
font-weight: bold;
font-size: 1.1em;
margin: 10px;
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
.button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
background: #fff;
}
.code {
background: rgba(0, 0, 0, 0.3);
padding: 15px;
border-radius: 10px;
margin: 20px 0;
font-family: "Courier New", monospace;
font-size: 0.9em;
}
.emoji {
font-size: 3em;
margin-bottom: 20px;
} }
</style> </style>
</head>
<!-- PWA Meta Tags -->
<meta
name="description"
content="NoteJS - современная система заметок с поддержкой Markdown, изображений, тегов и календаря"
/>
<meta name="theme-color" content="#007bff" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
<meta name="apple-mobile-web-app-title" content="NoteJS" />
<meta name="apple-touch-fullscreen" content="yes" />
<meta name="msapplication-TileColor" content="#007bff" />
<meta name="msapplication-config" content="/browserconfig.xml" />
<meta name="msapplication-TileImage" content="/icons/icon-144x144.png" />
<meta name="application-name" content="NoteJS" />
<meta name="format-detection" content="telephone=no" />
<!-- Icons -->
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<link
rel="icon"
type="image/png"
sizes="32x32"
href="/icons/icon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="/icons/icon-16x16.png"
/>
<link rel="apple-touch-icon" sizes="57x57" href="/icons/icon-48x48.png" />
<link rel="apple-touch-icon" sizes="60x60" href="/icons/icon-48x48.png" />
<link rel="apple-touch-icon" sizes="72x72" href="/icons/icon-72x72.png" />
<link rel="apple-touch-icon" sizes="76x76" href="/icons/icon-72x72.png" />
<link
rel="apple-touch-icon"
sizes="114x114"
href="/icons/icon-128x128.png"
/>
<link
rel="apple-touch-icon"
sizes="120x120"
href="/icons/icon-128x128.png"
/>
<link
rel="apple-touch-icon"
sizes="144x144"
href="/icons/icon-144x144.png"
/>
<link
rel="apple-touch-icon"
sizes="152x152"
href="/icons/icon-152x152.png"
/>
<link
rel="apple-touch-icon"
sizes="180x180"
href="/icons/icon-192x192.png"
/>
<link rel="mask-icon" href="/icon.svg" color="#007bff" />
<!-- Manifest -->
<link rel="manifest" href="/manifest.json" />
<script type="module" crossorigin src="/assets/index-CRKRzJj1.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-QEK5TGz3.css">
<link rel="manifest" href="/manifest.webmanifest"></head>
<body> <body>
<div class="container"> <div id="root"></div>
<div class="emoji">🚀</div>
<h1>NoteJS Backend API</h1>
<div class="status">✅ Сервер работает</div>
<div class="info">
<p>Это Backend API сервер NoteJS React.</p>
<p>Для доступа к приложению используйте Frontend:</p>
</div>
<div class="code">
<strong>Frontend (Development):</strong><br />
http://localhost:5173
</div>
<a href="http://localhost:5173" class="button"> Открыть приложение </a>
<div
class="info"
style="margin-top: 40px; font-size: 0.9em; opacity: 0.8"
>
<p><strong>Backend API:</strong> http://localhost:3001</p>
<p>
<strong>Endpoints:</strong> /api/auth, /api/notes, /api/user, /api/ai
</p>
</div>
</div>
<script>
// Автоматическое перенаправление на frontend через 3 секунды
setTimeout(() => {
if (confirm("Перейти на frontend приложение?")) {
window.location.href = "http://localhost:5173";
}
}, 2000);
</script>
</body> </body>
</html> </html>

View File

@ -13,6 +13,23 @@
"categories": ["productivity", "utilities"], "categories": ["productivity", "utilities"],
"prefer_related_applications": false, "prefer_related_applications": false,
"display_override": ["window-controls-overlay", "standalone"], "display_override": ["window-controls-overlay", "standalone"],
"dir": "ltr",
"shortcuts": [
{
"name": "Новая заметка",
"short_name": "Новая",
"description": "Создать новую заметку",
"url": "/notes?new=true",
"icons": [{ "src": "/icons/icon-192x192.png", "sizes": "192x192" }]
},
{
"name": "Мой профиль",
"short_name": "Профиль",
"description": "Открыть профиль",
"url": "/profile",
"icons": [{ "src": "/icons/icon-192x192.png", "sizes": "192x192" }]
}
],
"screenshots": [ "screenshots": [
{ {
"src": "/icons/icon-512x512.png", "src": "/icons/icon-512x512.png",

View File

@ -0,0 +1 @@
{"name":"NoteJS - Система заметок","short_name":"NoteJS","start_url":"/","display":"standalone","background_color":"#ffffff","lang":"en","scope":"/","description":"Современная система заметок с поддержкой Markdown, изображений, тегов и календаря","theme_color":"#007bff","orientation":"portrait-primary","icons":[{"src":"/icons/icon-72x72.png","sizes":"72x72","type":"image/png","purpose":"any"},{"src":"/icons/icon-96x96.png","sizes":"96x96","type":"image/png","purpose":"any"},{"src":"/icons/icon-128x128.png","sizes":"128x128","type":"image/png","purpose":"any"},{"src":"/icons/icon-144x144.png","sizes":"144x144","type":"image/png","purpose":"any"},{"src":"/icons/icon-152x152.png","sizes":"152x152","type":"image/png","purpose":"any"},{"src":"/icons/icon-192x192.png","sizes":"192x192","type":"image/png","purpose":"any"},{"src":"/icons/icon-384x384.png","sizes":"384x384","type":"image/png","purpose":"any"},{"src":"/icons/icon-512x512.png","sizes":"512x512","type":"image/png","purpose":"any"},{"src":"/icons/icon-192x192.png","sizes":"192x192","type":"image/png","purpose":"maskable"},{"src":"/icons/icon-512x512.png","sizes":"512x512","type":"image/png","purpose":"maskable"}]}

View File

@ -1,17 +1 @@
// NoteJS Backend Service Worker if(!self.define){let e,i={};const n=(n,c)=>(n=new URL(n+".js",c).href,i[n]||new Promise(i=>{if("document"in self){const e=document.createElement("script");e.src=n,e.onload=i,document.head.appendChild(e)}else e=n,importScripts(n),i()}).then(()=>{let e=i[n];if(!e)throw new Error(`Module ${n} didnt register its module`);return e}));self.define=(c,o)=>{const s=e||("document"in self?document.currentScript.src:"")||location.href;if(i[s])return;let r={};const d=e=>n(e,s),a={module:{uri:s},exports:r,require:d};i[s]=Promise.all(c.map(e=>a[e]||d(e))).then(e=>(o(...e),r))}}define(["./workbox-57555046"],function(e){"use strict";self.addEventListener("message",e=>{e.data&&"SKIP_WAITING"===e.data.type&&self.skipWaiting()}),e.precacheAndRoute([{url:"assets/index-CRKRzJj1.js",revision:null},{url:"assets/index-QEK5TGz3.css",revision:null},{url:"assets/workbox-window.prod.es5-B9K5rw8f.js",revision:null},{url:"icon.svg",revision:"537ae73d8f9e90e6a01816aa6d527d16"},{url:"icons/icon-128x128.png",revision:"fa71db17e345406d5f7d847f88c65ac4"},{url:"icons/icon-144x144.png",revision:"e790ff42758ea1a2a46eb84201630757"},{url:"icons/icon-152x152.png",revision:"88f2400f6617a32cc9cd62c70fb49a05"},{url:"icons/icon-16x16.png",revision:"101c13808e9fd0956f247bc446a8ac1e"},{url:"icons/icon-192x192.png",revision:"7d86d2d2ada99d7cee015dff0fdcb497"},{url:"icons/icon-32x32.png",revision:"22ee5d42535bc339ab0e19cb496378a5"},{url:"icons/icon-384x384.png",revision:"c601fa602952a903389e5e8f8a699617"},{url:"icons/icon-48x48.png",revision:"cfdd3bebd931375f2e0277d638ec8781"},{url:"icons/icon-512x512.png",revision:"8731edef999b9e7deba310d72a739925"},{url:"icons/icon-72x72.png",revision:"6b3cb1b2537ec91921698260a9c2f47c"},{url:"icons/icon-96x96.png",revision:"7efd757a81217207d981de88ef199d86"},{url:"index.html",revision:"52c85beb0841c0c7c8ddf774370cff39"},{url:"logo.svg",revision:"11616ede8898b4c24203e331b3ec6dc3"},{url:"icons/icon-72x72.png",revision:"6b3cb1b2537ec91921698260a9c2f47c"},{url:"icons/icon-96x96.png",revision:"7efd757a81217207d981de88ef199d86"},{url:"icons/icon-128x128.png",revision:"fa71db17e345406d5f7d847f88c65ac4"},{url:"icons/icon-144x144.png",revision:"e790ff42758ea1a2a46eb84201630757"},{url:"icons/icon-152x152.png",revision:"88f2400f6617a32cc9cd62c70fb49a05"},{url:"icons/icon-192x192.png",revision:"7d86d2d2ada99d7cee015dff0fdcb497"},{url:"icons/icon-384x384.png",revision:"c601fa602952a903389e5e8f8a699617"},{url:"icons/icon-512x512.png",revision:"8731edef999b9e7deba310d72a739925"},{url:"manifest.webmanifest",revision:"1c071cadebd7a1b0dc1eeb0270e73fb8"}],{}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("index.html"))),e.registerRoute(/^https:\/\/api\./i,new e.NetworkFirst({cacheName:"api-cache",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:50,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\.(?:png|jpg|jpeg|svg|gif|webp)$/i,new e.CacheFirst({cacheName:"image-cache",plugins:[new e.ExpirationPlugin({maxEntries:100,maxAgeSeconds:2592e3})]}),"GET")});
// Минимальный SW для предотвращения ошибок
self.addEventListener("install", (event) => {
console.log("Backend SW installed");
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
console.log("Backend SW activated");
event.waitUntil(self.clients.claim());
});
self.addEventListener("fetch", (event) => {
// Просто пропускаем все запросы через сеть
event.respondWith(fetch(event.request));
});

File diff suppressed because one or more lines are too long

1
dev-dist/registerSW.js Normal file
View File

@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

109
dev-dist/sw.js Normal file
View File

@ -0,0 +1,109 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-8cfb3eb5'], (function (workbox) { 'use strict';
self.addEventListener('message', event => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
});
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.h9luj2l3mv8"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/]
}));
workbox.registerRoute(/^https:\/\/api\./, new workbox.NetworkFirst({
"cacheName": "api-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 3600
})]
}), 'GET');
workbox.registerRoute(/\.(?:png|jpg|jpeg|svg|gif|webp)$/, new workbox.CacheFirst({
"cacheName": "images-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 2592000
})]
}), 'GET');
}));

4600
dev-dist/workbox-8cfb3eb5.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -9,10 +9,10 @@
"orientation": "portrait-primary", "orientation": "portrait-primary",
"scope": "/", "scope": "/",
"lang": "ru", "lang": "ru",
"id": "/",
"categories": ["productivity", "utilities"], "categories": ["productivity", "utilities"],
"prefer_related_applications": false, "prefer_related_applications": false,
"display_override": ["window-controls-overlay", "standalone"], "display_override": ["window-controls-overlay", "standalone"],
"dir": "ltr",
"screenshots": [ "screenshots": [
{ {
"src": "/icons/icon-512x512.png", "src": "/icons/icon-512x512.png",

View File

@ -8,6 +8,7 @@ import NotesPage from "./pages/NotesPage";
import ProfilePage from "./pages/ProfilePage"; import ProfilePage from "./pages/ProfilePage";
import SettingsPage from "./pages/SettingsPage"; import SettingsPage from "./pages/SettingsPage";
import { NotificationStack } from "./components/common/Notification"; import { NotificationStack } from "./components/common/Notification";
import { InstallPrompt } from "./components/common/InstallPrompt";
import { ProtectedRoute } from "./components/ProtectedRoute"; import { ProtectedRoute } from "./components/ProtectedRoute";
import { useTheme } from "./hooks/useTheme"; import { useTheme } from "./hooks/useTheme";
@ -17,6 +18,7 @@ const AppContent: React.FC = () => {
return ( return (
<> <>
<NotificationStack /> <NotificationStack />
<InstallPrompt />
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<LoginPage />} /> <Route path="/" element={<LoginPage />} />

View File

@ -0,0 +1,155 @@
import React, { useState, useEffect } from "react";
import { Icon } from "@iconify/react";
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
}
export const InstallPrompt: React.FC = () => {
const [deferredPrompt, setDeferredPrompt] =
useState<BeforeInstallPromptEvent | null>(null);
const [showPrompt, setShowPrompt] = useState(false);
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
setDeferredPrompt(e as BeforeInstallPromptEvent);
// Показываем промпт через небольшую задержку
setTimeout(() => setShowPrompt(true), 2000);
};
window.addEventListener("beforeinstallprompt", handler);
// Проверяем, не установлено ли приложение уже
if (window.matchMedia("(display-mode: standalone)").matches) {
setShowPrompt(false);
}
return () => {
window.removeEventListener("beforeinstallprompt", handler);
};
}, []);
const handleInstallClick = async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
if (outcome === "accepted") {
console.log("Пользователь принял предложение об установке");
} else {
console.log("Пользователь отклонил предложение об установке");
}
setDeferredPrompt(null);
setShowPrompt(false);
};
const handleDismiss = () => {
setShowPrompt(false);
// Сохраняем в localStorage, что пользователь закрыл промпт
localStorage.setItem("pwa-install-dismissed", "true");
};
// Не показываем, если пользователь уже закрыл промпт
useEffect(() => {
if (localStorage.getItem("pwa-install-dismissed") === "true") {
setShowPrompt(false);
}
}, []);
if (!showPrompt || !deferredPrompt) {
return null;
}
return (
<div
style={{
position: "fixed",
bottom: "20px",
left: "50%",
transform: "translateX(-50%)",
backgroundColor: "var(--bg-primary)",
border: "1px solid var(--border-primary)",
borderRadius: "12px",
padding: "16px 20px",
boxShadow: "0 4px 20px var(--shadow-light)",
zIndex: 1000,
maxWidth: "90%",
width: "400px",
display: "flex",
alignItems: "center",
gap: "12px",
}}
>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: "4px",
}}
>
<div
style={{
fontWeight: 600,
fontSize: "14px",
color: "var(--text-primary)",
}}
>
Установить NoteJS?
</div>
<div
style={{
fontSize: "12px",
color: "var(--text-primary)",
opacity: 0.7,
}}
>
Установите приложение для быстрого доступа
</div>
</div>
<div style={{ display: "flex", gap: "8px" }}>
<button
onClick={handleInstallClick}
style={{
padding: "8px 16px",
backgroundColor: "var(--accent-color)",
color: "white",
border: "none",
borderRadius: "8px",
cursor: "pointer",
fontSize: "14px",
fontWeight: 500,
display: "flex",
alignItems: "center",
gap: "6px",
}}
>
<Icon icon="mdi:download" width="18" height="18" />
Установить
</button>
<button
onClick={handleDismiss}
style={{
padding: "8px",
backgroundColor: "transparent",
color: "var(--text-primary)",
border: "none",
borderRadius: "8px",
cursor: "pointer",
display: "flex",
alignItems: "center",
opacity: 0.6,
}}
aria-label="Закрыть"
>
<Icon icon="mdi:close" width="20" height="20" />
</button>
</div>
</div>
);
};

View File

@ -2,11 +2,6 @@ import React, { useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { useAppSelector, useAppDispatch } from "../../store/hooks"; import { useAppSelector, useAppDispatch } from "../../store/hooks";
import {
setSelectedDate,
setSelectedTag,
setSearchQuery,
} from "../../store/slices/notesSlice";
import { userApi } from "../../api/userApi"; import { userApi } from "../../api/userApi";
import { ThemeToggle } from "../common/ThemeToggle"; import { ThemeToggle } from "../common/ThemeToggle";
import { setUser, setAiSettings } from "../../store/slices/profileSlice"; import { setUser, setAiSettings } from "../../store/slices/profileSlice";
@ -60,35 +55,6 @@ export const Header: React.FC<HeaderProps> = ({
} }
}; };
const handleClearFilters = () => {
dispatch(setSelectedDate(null));
dispatch(setSelectedTag(null));
dispatch(setSearchQuery(""));
};
const hasFilters = !!(selectedDate || selectedTag || searchQuery);
// Формируем список активных фильтров
const getActiveFilters = () => {
const filters: string[] = [];
if (searchQuery) {
filters.push(`Поиск: "${searchQuery}"`);
}
if (selectedDate) {
filters.push(`Дата: ${selectedDate}`);
}
if (selectedTag) {
filters.push(`Тег: #${selectedTag}`);
}
return filters;
};
const activeFilters = getActiveFilters();
return ( return (
<> <>
{/* Кнопка мобильного меню */} {/* Кнопка мобильного меню */}
@ -103,14 +69,6 @@ export const Header: React.FC<HeaderProps> = ({
<span> <span>
<Icon icon="mdi:note-text" /> Мои заметки <Icon icon="mdi:note-text" /> Мои заметки
</span> </span>
{hasFilters && (
<div className="filter-indicator">
<span className="filter-indicator-text">
Фильтр: {activeFilters.join(", ")}
</span>{" "}
<button onClick={handleClearFilters}></button>
</div>
)}
</div> </div>
<div className="user-info"> <div className="user-info">
{user?.avatar ? ( {user?.avatar ? (

View File

@ -27,7 +27,10 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0); const [startX, setStartX] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0); const [scrollLeft, setScrollLeft] = useState(0);
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null); const [menuPosition, setMenuPosition] = useState<{
top: number;
left: number;
} | null>(null);
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {

View File

@ -831,7 +831,8 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
const textarea = textareaRef.current; const textarea = textareaRef.current;
if (textarea) { if (textarea) {
const hasText = textarea.value.trim().length > 0; const hasText = textarea.value.trim().length > 0;
const hasSelection = textarea.selectionStart !== textarea.selectionEnd; const hasSelection =
textarea.selectionStart !== textarea.selectionEnd;
// Блокируем браузерное меню только если есть текст И есть выделение // Блокируем браузерное меню только если есть текст И есть выделение
if (hasText && hasSelection) { if (hasText && hasSelection) {
e.preventDefault(); e.preventDefault();

View File

@ -1115,7 +1115,8 @@ export const NoteItem: React.FC<NoteItemProps> = ({
const textarea = editTextareaRef.current; const textarea = editTextareaRef.current;
if (textarea) { if (textarea) {
const hasText = textarea.value.trim().length > 0; const hasText = textarea.value.trim().length > 0;
const hasSelection = textarea.selectionStart !== textarea.selectionEnd; const hasSelection =
textarea.selectionStart !== textarea.selectionEnd;
// Блокируем браузерное меню только если есть текст И есть выделение // Блокируем браузерное меню только если есть текст И есть выделение
if (hasText && hasSelection) { if (hasText && hasSelection) {
e.preventDefault(); e.preventDefault();
@ -1337,7 +1338,9 @@ export const NoteItem: React.FC<NoteItemProps> = ({
<> <>
<div <div
ref={textNoteRef} ref={textNoteRef}
className={`textNote ${isLongNote && !isExpanded ? "collapsed" : ""}`} className={`textNote ${
isLongNote && !isExpanded ? "collapsed" : ""
}`}
data-original-content={note.content} data-original-content={note.content}
dangerouslySetInnerHTML={{ __html: formatContent() }} dangerouslySetInnerHTML={{ __html: formatContent() }}
onClick={(e) => { onClick={(e) => {
@ -1356,7 +1359,9 @@ export const NoteItem: React.FC<NoteItemProps> = ({
onClick={toggleExpand} onClick={toggleExpand}
type="button" type="button"
> >
<Icon icon={isExpanded ? "mdi:chevron-up" : "mdi:chevron-down"} /> <Icon
icon={isExpanded ? "mdi:chevron-up" : "mdi:chevron-down"}
/>
<span>{isExpanded ? "Скрыть" : "Раскрыть"}</span> <span>{isExpanded ? "Скрыть" : "Раскрыть"}</span>
</button> </button>
)} )}

View File

@ -111,9 +111,7 @@ export const NotesList = forwardRef<NotesListRef>((props, ref) => {
return ( return (
<div className="notes-container"> <div className="notes-container">
<p className="empty-message"> <p className="empty-message">{message}</p>
{message}
</p>
</div> </div>
); );
} }

View File

@ -6,6 +6,8 @@ import "./styles/theme.css";
import "./styles/style.css"; import "./styles/style.css";
import "./styles/style-calendar.css"; import "./styles/style-calendar.css";
// Регистрация PWA (vite-plugin-pwa автоматически внедряет регистрацию через injectRegister: "auto")
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<App /> <App />

View File

@ -135,11 +135,6 @@ const LoginPage: React.FC = () => {
Нет аккаунта? <Link to="/register">Зарегистрируйтесь</Link> Нет аккаунта? <Link to="/register">Зарегистрируйтесь</Link>
</p> </p>
</div> </div>
<div className="footer">
<p>
Создатель: <span>Fovway</span>
</p>
</div>
</div> </div>
); );
}; };

View File

@ -5,12 +5,49 @@ import { MobileSidebar } from "../components/layout/MobileSidebar";
import { NoteEditor } from "../components/notes/NoteEditor"; import { NoteEditor } from "../components/notes/NoteEditor";
import { NotesList, NotesListRef } from "../components/notes/NotesList"; import { NotesList, NotesListRef } from "../components/notes/NotesList";
import { ImageModal } from "../components/common/ImageModal"; import { ImageModal } from "../components/common/ImageModal";
import { useAppSelector } from "../store/hooks"; import { useAppSelector, useAppDispatch } from "../store/hooks";
import {
setSelectedDate,
setSelectedTag,
setSearchQuery,
} from "../store/slices/notesSlice";
const NotesPage: React.FC = () => { const NotesPage: React.FC = () => {
const allNotes = useAppSelector((state) => state.notes.allNotes); const allNotes = useAppSelector((state) => state.notes.allNotes);
const notesListRef = useRef<NotesListRef>(null); const notesListRef = useRef<NotesListRef>(null);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false); const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const dispatch = useAppDispatch();
const selectedDate = useAppSelector((state) => state.notes.selectedDate);
const selectedTag = useAppSelector((state) => state.notes.selectedTag);
const searchQuery = useAppSelector((state) => state.notes.searchQuery);
const hasFilters = !!(selectedDate || selectedTag || searchQuery);
const handleClearFilters = () => {
dispatch(setSelectedDate(null));
dispatch(setSelectedTag(null));
dispatch(setSearchQuery(""));
};
const getActiveFilters = () => {
const filters: string[] = [];
if (searchQuery) {
filters.push(`Поиск: "${searchQuery}"`);
}
if (selectedDate) {
filters.push(`Дата: ${selectedDate}`);
}
if (selectedTag) {
filters.push(`Тег: #${selectedTag}`);
}
return filters;
};
const activeFilters = getActiveFilters();
const handleNoteSave = () => { const handleNoteSave = () => {
// Вызываем перезагрузку заметок после создания новой заметки // Вызываем перезагрузку заметок после создания новой заметки
@ -37,14 +74,17 @@ const NotesPage: React.FC = () => {
<div className="center"> <div className="center">
<div className="container"> <div className="container">
<Header onToggleSidebar={handleToggleMobileSidebar} /> <Header onToggleSidebar={handleToggleMobileSidebar} />
{hasFilters && (
<div className="filter-indicator">
<span className="filter-indicator-text">
Фильтр: {activeFilters.join(", ")}
</span>{" "}
<button onClick={handleClearFilters}></button>
</div>
)}
<NoteEditor onSave={handleNoteSave} /> <NoteEditor onSave={handleNoteSave} />
</div> </div>
<NotesList ref={notesListRef} /> <NotesList ref={notesListRef} />
<div className="footer">
<p>
Создатель: <span>Fovway</span>
</p>
</div>
</div> </div>
<ImageModal /> <ImageModal />
</> </>

View File

@ -156,11 +156,6 @@ const RegisterPage: React.FC = () => {
Уже есть аккаунт? <Link to="/">Войдите</Link> Уже есть аккаунт? <Link to="/">Войдите</Link>
</p> </p>
</div> </div>
<div className="footer">
<p>
Создатель: <span>Fovway</span>
</p>
</div>
</div> </div>
); );
}; };

View File

@ -329,19 +329,22 @@ header .iconify[data-icon="mdi:account-plus"] {
header { header {
font-size: 20px; font-size: 20px;
font-weight: bold; font-weight: bold;
margin-bottom: 20px;
} }
.notes-header { .notes-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
width: 100%;
box-sizing: border-box;
} }
.notes-header-left { .notes-header-left {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
flex: 1;
min-width: 0;
} }
/* Стили для секции поиска в левой панели */ /* Стили для секции поиска в левой панели */
@ -408,7 +411,7 @@ header {
.filter-indicator { .filter-indicator {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
margin-top: 5px; margin-top: 15px;
padding: 4px 10px; padding: 4px 10px;
background-color: rgba(var(--accent-color-rgb), 0.1); background-color: rgba(var(--accent-color-rgb), 0.1);
border: 1px solid rgba(var(--accent-color-rgb), 0.3); border: 1px solid rgba(var(--accent-color-rgb), 0.3);
@ -416,7 +419,8 @@ header {
font-size: 12px; font-size: 12px;
color: var(--accent-color); color: var(--accent-color);
font-weight: 500; font-weight: 500;
max-width: 300px; max-width: min(300px, 100%);
box-sizing: border-box;
} }
.filter-indicator-text { .filter-indicator-text {
@ -454,6 +458,7 @@ header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 15px; gap: 15px;
flex-shrink: 0;
} }
.user-info span { .user-info span {
@ -510,27 +515,25 @@ header {
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
font-size: 18px;
transition: all 0.3s ease;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.3s ease;
flex-shrink: 0; flex-shrink: 0;
} }
.settings-icon-btn:hover { .settings-icon-btn:hover {
background: var(--accent-color, #007bff); background-color: var(--accent-color, #007bff);
color: white; color: white;
transform: rotate(45deg); transform: scale(1.1);
} }
.settings-icon-btn .iconify { .settings-icon-btn .iconify {
font-size: 20px; font-size: 18px;
color: #666; color: var(--text-secondary);
transition: color 0.3s ease; transition: color 0.3s ease;
margin: 0; margin: 0;
padding: 0;
line-height: 1;
vertical-align: baseline;
} }
.settings-icon-btn:hover .iconify { .settings-icon-btn:hover .iconify {
@ -2698,10 +2701,8 @@ textarea:focus {
.user-info { .user-info {
flex-shrink: 0; flex-shrink: 0;
justify-content: flex-end;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
max-width: calc(100vw - 120px);
} }
.user-info .back-btn { .user-info .back-btn {
@ -2717,7 +2718,7 @@ textarea:focus {
/* Дополнительная адаптация для очень маленьких экранов */ /* Дополнительная адаптация для очень маленьких экранов */
@media (max-width: 480px) { @media (max-width: 480px) {
.user-info { .user-info {
max-width: calc(100vw - 100px); flex-shrink: 0;
gap: 4px; gap: 4px;
} }
@ -2735,6 +2736,16 @@ textarea:focus {
.user-info .theme-toggle-btn .iconify { .user-info .theme-toggle-btn .iconify {
font-size: 16px; font-size: 16px;
} }
.settings-icon-btn {
width: 32px;
height: 32px;
padding: 6px;
}
.settings-icon-btn .iconify {
font-size: 16px;
}
} }
/* Адаптируем кнопки markdown */ /* Адаптируем кнопки markdown */
@ -4750,18 +4761,12 @@ textarea:focus {
gap: 8px; gap: 8px;
} }
.user-info {
justify-content: flex-end;
}
/* Фильтры */ /* Фильтры */
.filter-indicator { .filter-indicator {
font-size: 12px; font-size: 12px;
display: inline-flex !important; display: inline-flex !important;
width: auto !important; max-width: min(300px, 100%) !important;
min-width: 0 !important; width: fit-content !important;
flex: 0 0 auto !important;
align-self: flex-start !important;
box-sizing: border-box !important; box-sizing: border-box !important;
} }

View File

@ -8,13 +8,122 @@ export default defineConfig({
react(), react(),
VitePWA({ VitePWA({
registerType: "prompt", registerType: "prompt",
injectRegister: "auto",
includeAssets: [
"icon.svg",
"icons/icon-192x192.png",
"icons/icon-512x512.png",
],
manifest: {
name: "NoteJS - Система заметок",
short_name: "NoteJS",
description:
"Современная система заметок с поддержкой Markdown, изображений, тегов и календаря",
theme_color: "#007bff",
background_color: "#ffffff",
display: "standalone",
orientation: "portrait-primary",
scope: "/",
start_url: "/",
icons: [
{
src: "/icons/icon-72x72.png",
sizes: "72x72",
type: "image/png",
purpose: "any",
},
{
src: "/icons/icon-96x96.png",
sizes: "96x96",
type: "image/png",
purpose: "any",
},
{
src: "/icons/icon-128x128.png",
sizes: "128x128",
type: "image/png",
purpose: "any",
},
{
src: "/icons/icon-144x144.png",
sizes: "144x144",
type: "image/png",
purpose: "any",
},
{
src: "/icons/icon-152x152.png",
sizes: "152x152",
type: "image/png",
purpose: "any",
},
{
src: "/icons/icon-192x192.png",
sizes: "192x192",
type: "image/png",
purpose: "any",
},
{
src: "/icons/icon-384x384.png",
sizes: "384x384",
type: "image/png",
purpose: "any",
},
{
src: "/icons/icon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/icons/icon-192x192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/icons/icon-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "maskable",
},
],
},
workbox: { workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg}"], globPatterns: ["**/*.{js,css,html,ico,png,svg,woff,woff2,ttf,eot}"],
runtimeCaching: [
{
urlPattern: /^https:\/\/api\./,
handler: "NetworkFirst",
options: {
cacheName: "api-cache",
expiration: {
maxEntries: 50,
maxAgeSeconds: 60 * 60, // 1 hour
},
},
},
{
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp)$/,
handler: "CacheFirst",
options: {
cacheName: "images-cache",
expiration: {
maxEntries: 100,
maxAgeSeconds: 30 * 24 * 60 * 60, // 30 days
},
},
},
],
},
devOptions: {
enabled: true,
type: "module",
}, },
}), }),
], ],
server: { server: {
port: 5173, port: 5173,
allowedHosts: ["notes.fovway.ru", "localhost"],
proxy: { proxy: {
"/api": { "/api": {
target: "http://localhost:3001", target: "http://localhost:3001",