Добавлена поддержка 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

@ -1,177 +1,167 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#667eea" />
<title>NoteJS Backend</title>
<!-- Предотвращение мерцания темы -->
<script>
(function () {
try {
// Получаем сохраненную тему
const savedTheme = localStorage.getItem("theme");
// Получаем системные предпочтения
const systemPrefersDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
// Определяем тему: сохраненная или системная
const theme = savedTheme || (systemPrefersDark ? "dark" : "light");
// Устанавливаем тему до загрузки CSS
if (theme === "dark") {
document.documentElement.setAttribute("data-theme", "dark");
} else {
document.documentElement.setAttribute("data-theme", "light");
}
// Получаем и устанавливаем accentColor
const savedAccentColor = localStorage.getItem("accentColor");
const accentColor = savedAccentColor || "#667eea";
// Устанавливаем CSS переменную для accent цвета
document.documentElement.style.setProperty("--accent-color", accentColor);
// Устанавливаем цвет для meta theme-color
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) {
themeColorMeta.setAttribute(
"content",
theme === "dark" ? "#1a1a1a" : accentColor
);
}
} catch (e) {
// В случае ошибки устанавливаем светлую тему по умолчанию
document.documentElement.setAttribute("data-theme", "light");
document.documentElement.style.setProperty("--accent-color", "#667eea");
}
})();
</script>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<title>NoteJS - Система заметок</title>
<!-- Предотвращение мерцания темы -->
<script>
(function () {
try {
// Получаем сохраненную тему
const savedTheme = localStorage.getItem("theme");
// Получаем системные предпочтения
const systemPrefersDark = window.matchMedia(
"(prefers-color-scheme: dark)"
).matches;
// Определяем тему: сохраненная или системная
const theme = savedTheme || (systemPrefersDark ? "dark" : "light");
// Функция для конвертации 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") {
document.documentElement.setAttribute("data-theme", "dark");
} else {
document.documentElement.setAttribute("data-theme", "light");
}
// Устанавливаем CSS переменные для accent цвета
document.documentElement.style.setProperty("--accent-color", accentColor);
document.documentElement.style.setProperty("--accent-color-rgb", hexToRgb(accentColor));
// Устанавливаем цвет для meta theme-color
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta) {
themeColorMeta.setAttribute(
"content",
theme === "dark" ? "#1a1a1a" : accentColor
);
}
} catch (e) {
// В случае ошибки устанавливаем светлую тему по умолчанию
document.documentElement.setAttribute("data-theme", "light");
document.documentElement.style.setProperty("--accent-color", "#007bff");
document.documentElement.style.setProperty("--accent-color-rgb", "0, 123, 255");
}
})();
</script>
<!-- Критические стили темы для предотвращения flash эффекта -->
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
:root {
--accent-color: #007bff;
/* Светлая тема (по умолчанию) */
--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 {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.3s ease, color 0.3s ease;
}
.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>
</head>
<body>
<div class="container">
<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>
</html>
</style>
<!-- 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>
<div id="root"></div>
</body>
</html>

View File

@ -13,6 +13,23 @@
"categories": ["productivity", "utilities"],
"prefer_related_applications": false,
"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": [
{
"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
// Минимальный 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));
});
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")});

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",
"scope": "/",
"lang": "ru",
"id": "/",
"categories": ["productivity", "utilities"],
"prefer_related_applications": false,
"display_override": ["window-controls-overlay", "standalone"],
"dir": "ltr",
"screenshots": [
{
"src": "/icons/icon-512x512.png",

View File

@ -8,6 +8,7 @@ import NotesPage from "./pages/NotesPage";
import ProfilePage from "./pages/ProfilePage";
import SettingsPage from "./pages/SettingsPage";
import { NotificationStack } from "./components/common/Notification";
import { InstallPrompt } from "./components/common/InstallPrompt";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { useTheme } from "./hooks/useTheme";
@ -17,6 +18,7 @@ const AppContent: React.FC = () => {
return (
<>
<NotificationStack />
<InstallPrompt />
<BrowserRouter>
<Routes>
<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 { Icon } from "@iconify/react";
import { useAppSelector, useAppDispatch } from "../../store/hooks";
import {
setSelectedDate,
setSelectedTag,
setSearchQuery,
} from "../../store/slices/notesSlice";
import { userApi } from "../../api/userApi";
import { ThemeToggle } from "../common/ThemeToggle";
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 (
<>
{/* Кнопка мобильного меню */}
@ -103,14 +69,6 @@ export const Header: React.FC<HeaderProps> = ({
<span>
<Icon icon="mdi:note-text" /> Мои заметки
</span>
{hasFilters && (
<div className="filter-indicator">
<span className="filter-indicator-text">
Фильтр: {activeFilters.join(", ")}
</span>{" "}
<button onClick={handleClearFilters}></button>
</div>
)}
</div>
<div className="user-info">
{user?.avatar ? (

View File

@ -27,7 +27,10 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = 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(() => {
const handleClickOutside = (event: MouseEvent) => {
@ -163,7 +166,7 @@ export const MarkdownToolbar: React.FC<MarkdownToolbarProps> = ({
/>
</button>
{showHeaderDropdown && menuPosition && (
<div
<div
ref={menuRef}
className="header-dropdown-menu"
style={{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,12 +5,49 @@ import { MobileSidebar } from "../components/layout/MobileSidebar";
import { NoteEditor } from "../components/notes/NoteEditor";
import { NotesList, NotesListRef } from "../components/notes/NotesList";
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 allNotes = useAppSelector((state) => state.notes.allNotes);
const notesListRef = useRef<NotesListRef>(null);
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 = () => {
// Вызываем перезагрузку заметок после создания новой заметки
@ -37,14 +74,17 @@ const NotesPage: React.FC = () => {
<div className="center">
<div className="container">
<Header onToggleSidebar={handleToggleMobileSidebar} />
{hasFilters && (
<div className="filter-indicator">
<span className="filter-indicator-text">
Фильтр: {activeFilters.join(", ")}
</span>{" "}
<button onClick={handleClearFilters}></button>
</div>
)}
<NoteEditor onSave={handleNoteSave} />
</div>
<NotesList ref={notesListRef} />
<div className="footer">
<p>
Создатель: <span>Fovway</span>
</p>
</div>
</div>
<ImageModal />
</>

View File

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

View File

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

View File

@ -8,13 +8,122 @@ export default defineConfig({
react(),
VitePWA({
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: {
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: {
port: 5173,
allowedHosts: ["notes.fovway.ru", "localhost"],
proxy: {
"/api": {
target: "http://localhost:3001",