Fix today race condition (#75)
* Move frontend to root * Fix backend issues * Remove old routes * Setup Dockerfile * Fix today /tags multiplt requests issue * Fix race condition on today's inbox widget * Fix cors development issue * Fix CORS for Dockerfile * Fix dockerised settings for infinite loop * Fix translation issues * fixup! Fix translation issues --------- Co-authored-by: Your Name <you@example.com>
This commit is contained in:
parent
9b1e465b83
commit
f9b21dff0a
106 changed files with 2435 additions and 1451 deletions
123
Dockerfile
123
Dockerfile
|
|
@ -1,55 +1,108 @@
|
|||
# Stage 1: Build the React frontend
|
||||
FROM node:16 AS frontend-builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy and install frontend dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy the rest of the frontend code
|
||||
COPY . .
|
||||
|
||||
# Build the frontend assets
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Build the Sinatra backend
|
||||
# Use a base image that supports both Node.js and Ruby
|
||||
FROM ruby:3.2.2-slim
|
||||
|
||||
# Install necessary packages
|
||||
RUN apt-get update -qq && apt-get install -y \
|
||||
# Install Node.js and necessary packages
|
||||
RUN apt-get update -qq && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libsqlite3-dev \
|
||||
openssl \
|
||||
libffi-dev \
|
||||
libpq-dev
|
||||
libpq-dev \
|
||||
curl \
|
||||
gnupg2 \
|
||||
ca-certificates && \
|
||||
# Install Node.js 20
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \
|
||||
apt-get install -y nodejs && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
apt-get clean
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Copy and install backend dependencies
|
||||
# Install Ruby dependencies first
|
||||
COPY Gemfile* ./
|
||||
RUN bundle config set without 'development test' && bundle install
|
||||
RUN bundle config set --local deployment 'true' && \
|
||||
bundle config set --local without 'development test' && \
|
||||
bundle install --jobs 4 --retry 3
|
||||
|
||||
# Copy the backend code
|
||||
COPY . .
|
||||
# Install Node.js dependencies
|
||||
COPY package*.json ./
|
||||
COPY webpack.config.js ./
|
||||
COPY babel.config.js ./
|
||||
COPY tsconfig.json ./
|
||||
COPY postcss.config.js ./
|
||||
COPY tailwind.config.js ./
|
||||
RUN npm ci
|
||||
|
||||
# Remove any existing development databases
|
||||
RUN rm -f db/development*
|
||||
|
||||
# Copy built frontend assets from the frontend builder stage
|
||||
COPY --from=frontend-builder /app/public ./public
|
||||
# Copy application files
|
||||
COPY app/ app/
|
||||
COPY config/ config/
|
||||
COPY config.ru ./
|
||||
COPY Rakefile ./
|
||||
COPY app.rb ./
|
||||
COPY db/migrate/ db/migrate/
|
||||
COPY db/schema.rb db/schema.rb
|
||||
COPY frontend/ frontend/
|
||||
COPY public/ public/
|
||||
COPY src/ src/
|
||||
|
||||
# Expose the application port
|
||||
EXPOSE 9292
|
||||
# Create non-root user for security
|
||||
RUN useradd -m -U app && \
|
||||
chown -R app:app /usr/src/app
|
||||
|
||||
# Set environment variables
|
||||
ENV RACK_ENV=production
|
||||
ENV TUDUDI_INTERNAL_SSL_ENABLED=false
|
||||
USER app
|
||||
|
||||
# Generate SSL certificates
|
||||
# Expose ports for both frontend (8080) and backend (9292)
|
||||
EXPOSE 8080 9292
|
||||
|
||||
# Set production environment variables
|
||||
ENV RACK_ENV=production \
|
||||
NODE_ENV=production \
|
||||
TUDUDI_INTERNAL_SSL_ENABLED=false \
|
||||
TUDUDI_ALLOWED_ORIGINS="http://localhost:8080,http://localhost:9292,http://127.0.0.1:8080,http://127.0.0.1:9292,http://0.0.0.0:8080,http://0.0.0.0:9292" \
|
||||
LANG=C.UTF-8 \
|
||||
TZ=UTC
|
||||
|
||||
# Generate SSL certificates if needed
|
||||
RUN mkdir -p certs && \
|
||||
openssl req -x509 -newkey rsa:4096 -keyout certs/server.key -out certs/server.crt \
|
||||
-days 365 -nodes -subj '/CN=localhost'
|
||||
if [ "$TUDUDI_INTERNAL_SSL_ENABLED" = "true" ]; then \
|
||||
openssl req -x509 -newkey rsa:4096 \
|
||||
-keyout certs/server.key -out certs/server.crt \
|
||||
-days 365 -nodes \
|
||||
-subj '/CN=localhost' \
|
||||
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"; \
|
||||
fi
|
||||
|
||||
# Run database migrations and start the Puma server
|
||||
CMD rake db:migrate && puma -C app/config/puma.rb
|
||||
# Add healthcheck for backend
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:9292/api/health || exit 1
|
||||
|
||||
# Build production frontend assets
|
||||
RUN npm run build
|
||||
|
||||
# Copy translation files to dist folder for production serving
|
||||
RUN cp -r public/locales dist/
|
||||
|
||||
# Create startup script
|
||||
RUN echo '#!/bin/bash\n\
|
||||
set -e\n\
|
||||
\n\
|
||||
# Run database migrations\n\
|
||||
bundle exec rake db:migrate\n\
|
||||
\n\
|
||||
# Create user if it does not exist\n\
|
||||
if [ -n "$TUDUDI_USER_EMAIL" ] && [ -n "$TUDUDI_USER_PASSWORD" ]; then\n\
|
||||
echo "Creating user if it does not exist..."\n\
|
||||
echo "user = User.find_by(email: \"$TUDUDI_USER_EMAIL\") || User.create(email: \"$TUDUDI_USER_EMAIL\", password: \"$TUDUDI_USER_PASSWORD\"); puts \"User: #{user.email}\"" | bundle exec rake console\n\
|
||||
fi\n\
|
||||
\n\
|
||||
# Start backend with both API and static file serving\n\
|
||||
bundle exec puma -C app/config/puma.rb\n\
|
||||
' > start.sh && chmod +x start.sh
|
||||
|
||||
# Run both services
|
||||
CMD ["./start.sh"]
|
||||
|
|
@ -47,6 +47,8 @@ GEM
|
|||
ruby2_keywords (~> 0.0.1)
|
||||
mutex_m (0.2.0)
|
||||
nio4r (2.5.9)
|
||||
nokogiri (1.18.8-aarch64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.8-x86_64-linux-gnu)
|
||||
|
|
@ -107,6 +109,7 @@ GEM
|
|||
sinatra-cross_origin (0.4.0)
|
||||
sinatra-namespace (1.0)
|
||||
sinatra-contrib
|
||||
sqlite3 (1.6.8-aarch64-linux)
|
||||
sqlite3 (1.6.8-arm64-darwin)
|
||||
sqlite3 (1.6.8-x86_64-linux)
|
||||
tilt (2.3.0)
|
||||
|
|
@ -116,6 +119,7 @@ GEM
|
|||
unicode-display_width (2.5.0)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
arm64-darwin-22
|
||||
arm64-darwin-24
|
||||
x86_64-linux
|
||||
|
|
|
|||
103
app.rb
103
app.rb
|
|
@ -40,10 +40,13 @@ use Rack::MethodOverride
|
|||
|
||||
set :database_file, './app/config/database.yml'
|
||||
set :views, proc { File.join(root, 'app/views') }
|
||||
set :public_folder, 'public'
|
||||
set :public_folder, production? ? 'dist' : 'public'
|
||||
|
||||
configure do
|
||||
enable :cross_origin
|
||||
enable :sessions
|
||||
|
||||
# Session configuration
|
||||
secure_flag = production? && ENV['TUDUDI_INTERNAL_SSL_ENABLED'] == 'true'
|
||||
set :sessions, httponly: true,
|
||||
secure: secure_flag,
|
||||
|
|
@ -51,6 +54,19 @@ configure do
|
|||
same_site: secure_flag ? :none : :lax
|
||||
set :session_secret, ENV.fetch('TUDUDI_SESSION_SECRET') { SecureRandom.hex(64) }
|
||||
|
||||
# CORS configuration - use environment variable in production, fallback to localhost for development
|
||||
allowed_origins = if ENV['TUDUDI_ALLOWED_ORIGINS']
|
||||
ENV['TUDUDI_ALLOWED_ORIGINS'].split(',').map(&:strip)
|
||||
else
|
||||
['http://localhost:8080', 'http://localhost:9292', 'http://127.0.0.1:8080', 'http://127.0.0.1:9292']
|
||||
end
|
||||
set :allow_origin, allowed_origins
|
||||
set :allow_methods, %i[get post patch delete options]
|
||||
set :allow_credentials, true
|
||||
set :max_age, '1728000'
|
||||
set :expose_headers, ['Content-Type']
|
||||
set :allow_headers, %w[Authorization Content-Type Accept X-Requested-With]
|
||||
|
||||
# Ensure ActiveRecord connection is established
|
||||
ActiveRecord::Base.establish_connection
|
||||
|
||||
|
|
@ -67,38 +83,37 @@ configure do
|
|||
initialize_telegram_polling
|
||||
end
|
||||
|
||||
use Rack::Protection, except: [:http_origin]
|
||||
|
||||
before do
|
||||
require_login
|
||||
end
|
||||
|
||||
configure do
|
||||
enable :cross_origin
|
||||
# Rack Protection configuration - completely disable for development
|
||||
if development?
|
||||
# Disable Rack::Protection completely in development to avoid CSRF issues
|
||||
set :protection, false
|
||||
else
|
||||
# Use the same allowed origins for Rack::Protection in production
|
||||
use Rack::Protection,
|
||||
except: %i[remote_token session_hijacking remote_referrer],
|
||||
origin_whitelist: settings.allow_origin
|
||||
end
|
||||
|
||||
before do
|
||||
allowed_origins = ['http://localhost:8080', 'http://localhost:9292']
|
||||
|
||||
if ENV['TUDUDI_ALLOWED_ORIGINS']
|
||||
if ENV['TUDUDI_ALLOWED_ORIGINS'].strip.empty?
|
||||
response.headers['Access-Control-Allow-Origin'] = request.env['HTTP_ORIGIN']
|
||||
else
|
||||
allowed_origins.concat(ENV['TUDUDI_ALLOWED_ORIGINS'].split(',').map(&:strip))
|
||||
if request.env['HTTP_ORIGIN'] && allowed_origins.include?(request.env['HTTP_ORIGIN'])
|
||||
response.headers['Access-Control-Allow-Origin'] = request.env['HTTP_ORIGIN']
|
||||
end
|
||||
end
|
||||
# Handle CORS preflight requests
|
||||
if request.request_method == 'OPTIONS'
|
||||
response.headers['Access-Control-Allow-Methods'] = settings.allow_methods.map(&:to_s).join(', ')
|
||||
response.headers['Access-Control-Allow-Headers'] = settings.allow_headers.join(', ')
|
||||
response.headers['Access-Control-Max-Age'] = settings.max_age
|
||||
halt 200
|
||||
end
|
||||
|
||||
response.headers['Access-Control-Allow-Credentials'] = 'true'
|
||||
response.headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type, Accept, X-Requested-With'
|
||||
end
|
||||
# Set CORS headers for all requests
|
||||
if request.env['HTTP_ORIGIN'] && settings.allow_origin.include?(request.env['HTTP_ORIGIN'])
|
||||
response.headers['Access-Control-Allow-Origin'] = request.env['HTTP_ORIGIN']
|
||||
response.headers['Access-Control-Allow-Credentials'] = 'true'
|
||||
response.headers['Access-Control-Expose-Headers'] = settings.expose_headers.join(', ')
|
||||
end
|
||||
|
||||
options '*' do
|
||||
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
|
||||
response.headers['Access-Control-Allow-Headers'] = 'Authorization, Content-Type, Accept'
|
||||
200
|
||||
# Authentication check - only for API routes
|
||||
if request.path_info.start_with?('/api/') && !['/api/login', '/api/health'].include?(request.path_info)
|
||||
require_login
|
||||
end
|
||||
end
|
||||
|
||||
helpers do
|
||||
|
|
@ -150,7 +165,39 @@ helpers do
|
|||
end
|
||||
end
|
||||
|
||||
get '/*' do
|
||||
get '/' do
|
||||
if settings.production?
|
||||
# In production, serve the built index.html directly
|
||||
send_file File.join(settings.public_folder, 'index.html')
|
||||
else
|
||||
# In development, use ERB template
|
||||
erb :index
|
||||
end
|
||||
end
|
||||
|
||||
# Catch-all route for SPA routing in production
|
||||
get '*' do
|
||||
# Skip API routes and static assets
|
||||
unless request.path_info.start_with?('/api/') || request.path_info.match(/\.(js|css|png|jpg|jpeg|gif|ico|svg)$/)
|
||||
if settings.production?
|
||||
# In production, serve the built index.html for all SPA routes
|
||||
send_file File.join(settings.public_folder, 'index.html')
|
||||
else
|
||||
# In development, use ERB template
|
||||
erb :index
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Health check endpoint for Docker
|
||||
get '/api/health' do
|
||||
content_type :json
|
||||
{ status: 'ok', timestamp: Time.now.iso8601 }.to_json
|
||||
end
|
||||
|
||||
# Catch-all route for non-API routes to serve the SPA
|
||||
get '*' do
|
||||
pass if request.path_info.start_with?('/api/')
|
||||
erb :index
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,193 +0,0 @@
|
|||
import React, { useEffect, useState, Suspense, lazy } from "react";
|
||||
import {
|
||||
Routes,
|
||||
Route,
|
||||
useNavigate,
|
||||
Navigate,
|
||||
useLocation,
|
||||
} from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Login from "./components/Login";
|
||||
import NotFound from "./components/Shared/NotFound";
|
||||
import ProjectDetails from "./components/Project/ProjectDetails";
|
||||
import Projects from "./components/Projects";
|
||||
import AreaDetails from "./components/Area/AreaDetails";
|
||||
import Areas from "./components/Areas";
|
||||
import TagDetails from "./components/Tag/TagDetails";
|
||||
import Tags from "./components/Tags";
|
||||
import Notes from "./components/Notes";
|
||||
import NoteDetails from "./components/Note/NoteDetails";
|
||||
import ProfileSettings from "./components/Profile/ProfileSettings";
|
||||
import Layout from "./Layout";
|
||||
import { User } from "./entities/User";
|
||||
import TasksToday from "./components/Task/TasksToday";
|
||||
import LoadingScreen from "./components/Shared/LoadingScreen";
|
||||
import InboxItems from "./components/Inbox/InboxItems";
|
||||
// Lazy load Tasks component to prevent issues with tags loading
|
||||
const Tasks = lazy(() => import("./components/Tasks"));
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
if (!i18n.isInitialized) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
console.log("App component - i18n initialized:", i18n.isInitialized);
|
||||
console.log("App component - Current language:", i18n.language);
|
||||
console.log("App component - Has translation loaded:", i18n.hasResourceBundle(i18n.language, 'translation'));
|
||||
|
||||
// Force reload translations for the current language
|
||||
if (i18n.isInitialized) {
|
||||
// Create a direct fetch to verify the translation file is accessible
|
||||
fetch(`/locales/${i18n.language}/translation.json`)
|
||||
.then(response => {
|
||||
console.log(`Translation file fetch response: ${response.status} ${response.statusText}`);
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to fetch translation file: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log("Translation file content retrieved manually:", Object.keys(data));
|
||||
// Force add the resource bundle
|
||||
i18n.addResourceBundle(i18n.language, 'translation', data, true, true);
|
||||
console.log("Resource bundle manually added for:", i18n.language);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error manually fetching translation file:", error);
|
||||
});
|
||||
}
|
||||
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/current_user", {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.user) {
|
||||
setCurrentUser(data.user);
|
||||
|
||||
// Set the language based on user's profile if available
|
||||
if (data.user.language) {
|
||||
console.log("Setting language from user profile:", data.user.language);
|
||||
i18n.changeLanguage(data.user.language)
|
||||
.then(() => {
|
||||
console.log("Language changed to:", i18n.language);
|
||||
// After changing language, verify resource bundle
|
||||
console.log("Has resource bundle after change:",
|
||||
i18n.hasResourceBundle(i18n.language, 'translation'));
|
||||
})
|
||||
.catch(err => console.error("Error changing language:", err));
|
||||
}
|
||||
} else {
|
||||
navigate("/login");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch current user:", err);
|
||||
navigate("/login");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCurrentUser();
|
||||
}, [navigate]);
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
const newValue = !isDarkMode;
|
||||
setIsDarkMode(newValue);
|
||||
localStorage.setItem("isDarkMode", JSON.stringify(newValue));
|
||||
};
|
||||
|
||||
const [isDarkMode, setIsDarkMode] = useState<boolean>(() => {
|
||||
const storedPreference = localStorage.getItem("isDarkMode");
|
||||
return storedPreference !== null
|
||||
? storedPreference === "true"
|
||||
: window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const updateTheme = () => {
|
||||
document.documentElement.classList.toggle("dark", isDarkMode);
|
||||
};
|
||||
updateTheme();
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const mediaListener = (e: MediaQueryListEvent) => {
|
||||
if (!localStorage.getItem("isDarkMode")) {
|
||||
setIsDarkMode(e.matches);
|
||||
}
|
||||
};
|
||||
mediaQuery.addEventListener("change", mediaListener);
|
||||
return () => mediaQuery.removeEventListener("change", mediaListener);
|
||||
}, [isDarkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser && location.pathname === "/") {
|
||||
navigate("/today", { replace: true });
|
||||
}
|
||||
}, [currentUser, location.pathname, navigate]);
|
||||
|
||||
const LoadingComponent = () => (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
{i18n.t('common.loading', 'Loading application... Please wait.')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingComponent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LoadingComponent />}>
|
||||
{currentUser ? (
|
||||
<Layout
|
||||
currentUser={currentUser}
|
||||
setCurrentUser={setCurrentUser}
|
||||
isDarkMode={isDarkMode}
|
||||
toggleDarkMode={toggleDarkMode}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/today" replace />} />
|
||||
<Route path="/today" element={<TasksToday />} />
|
||||
<Route path="/tasks" element={
|
||||
<Suspense fallback={<div className="p-4">{i18n.t('common.loading', 'Loading...')}</div>}>
|
||||
<Tasks />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/inbox" element={<InboxItems />} />
|
||||
<Route path="/projects" element={<Projects />} />
|
||||
<Route path="/project/:id" element={<ProjectDetails />} />
|
||||
<Route path="/areas" element={<Areas />} />
|
||||
<Route path="/area/:id" element={<AreaDetails />} />
|
||||
<Route path="/tags" element={<Tags />} />
|
||||
<Route path="/tag/:id" element={<TagDetails />} />
|
||||
<Route path="/notes" element={<Notes />} />
|
||||
<Route path="/note/:id" element={<NoteDetails />} />
|
||||
<Route
|
||||
path="/profile"
|
||||
element={<ProfileSettings currentUser={currentUser} />}
|
||||
/>
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
) : (
|
||||
<Login />
|
||||
)}
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
|
@ -1,299 +0,0 @@
|
|||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import Backend from 'i18next-http-backend';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
|
||||
// Define required translations for the app to function even if translations fail to load
|
||||
const fallbackResources = {
|
||||
en: {
|
||||
translation: {
|
||||
common: {
|
||||
loading: 'Loading...',
|
||||
appLoading: 'Loading application... Please wait.',
|
||||
error: 'Error',
|
||||
},
|
||||
auth: {
|
||||
login: 'Login',
|
||||
register: 'Register',
|
||||
},
|
||||
errors: {
|
||||
somethingWentWrong: 'Something went wrong, please try again',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Explicitly add resources for development
|
||||
const devResources = isDevelopment ? {
|
||||
en: {
|
||||
translation: fallbackResources.en.translation,
|
||||
},
|
||||
} : undefined;
|
||||
|
||||
console.log("Initializing i18n...");
|
||||
console.log("Environment:", process.env.NODE_ENV);
|
||||
|
||||
// Create i18n instance
|
||||
const i18nInstance = i18n
|
||||
.use(Backend)
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next);
|
||||
|
||||
// Initialize i18n
|
||||
i18nInstance.init({
|
||||
fallbackLng: 'en',
|
||||
debug: isDevelopment,
|
||||
|
||||
// Map language codes with region (e.g., 'en-US') to base language codes (e.g., 'en')
|
||||
load: 'languageOnly',
|
||||
|
||||
// Language mapping to handle specific cases
|
||||
supportedLngs: ['en', 'es', 'el', 'jp', 'ua', 'de'],
|
||||
nonExplicitSupportedLngs: true,
|
||||
|
||||
// Add fallback resources to prevent rendering issues
|
||||
resources: devResources,
|
||||
|
||||
// Language detection options
|
||||
detection: {
|
||||
order: ['querystring', 'cookie', 'localStorage', 'navigator'],
|
||||
lookupQuerystring: 'lng',
|
||||
lookupCookie: 'i18next',
|
||||
lookupLocalStorage: 'i18nextLng',
|
||||
caches: ['localStorage', 'cookie']
|
||||
},
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false, // not needed for react as it escapes by default
|
||||
},
|
||||
|
||||
// Default namespace configuration
|
||||
defaultNS: 'translation',
|
||||
ns: ['translation'],
|
||||
|
||||
// Backend configuration for loading translations
|
||||
backend: {
|
||||
// Always use absolute path for development and production to avoid issues
|
||||
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
||||
// Add deterministic cache busting parameter based on build timestamp
|
||||
queryStringParams: { v: '1' },
|
||||
requestOptions: {
|
||||
cache: 'default', // Use default browser caching to improve performance
|
||||
credentials: 'same-origin',
|
||||
mode: 'cors'
|
||||
}
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
console.log('i18n initialized successfully');
|
||||
console.log('Loaded languages:', i18n.languages);
|
||||
console.log('Current language:', i18n.language);
|
||||
console.log('Available namespaces:', i18n.options.ns);
|
||||
console.log('Has translation bundle:', i18n.hasResourceBundle(i18n.language, 'translation'));
|
||||
|
||||
// Try to load translations directly with both possible paths
|
||||
const loadPath = isDevelopment ? `./locales/${i18n.language}/translation.json` : `/locales/${i18n.language}/translation.json`;
|
||||
console.log(`Attempting to fetch translations from: ${loadPath}`);
|
||||
|
||||
fetch(loadPath)
|
||||
.then(response => {
|
||||
console.log(`Manual fetch response: ${response.status} from ${loadPath}`);
|
||||
if (!response.ok) {
|
||||
// If first attempt fails and we're in development, try the alternative path
|
||||
if (isDevelopment) {
|
||||
console.log('First fetch attempt failed, trying alternative path');
|
||||
return fetch(`/locales/${i18n.language}/translation.json`);
|
||||
}
|
||||
throw new Error(`Failed to fetch translation: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Translation data fetched manually:', Object.keys(data));
|
||||
i18n.addResourceBundle(i18n.language, 'translation', data, true, true);
|
||||
console.log('Added resource bundle manually');
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Error manually fetching translations:', err);
|
||||
|
||||
// As a fallback, try to add translations from the public directory directly using require
|
||||
if (isDevelopment) {
|
||||
try {
|
||||
console.log('Attempting to load translations using a different approach...');
|
||||
setTimeout(() => {
|
||||
fetch(`/locales/${i18n.language}/translation.json`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
mode: 'cors'
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
i18n.addResourceBundle(i18n.language, 'translation', data, true, true);
|
||||
console.log('Added resource bundle via alternative approach');
|
||||
})
|
||||
.catch(e => console.error('Alternative loading approach failed:', e));
|
||||
}, 1000);
|
||||
} catch (e) {
|
||||
console.error('All attempts to load translations failed:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('i18n initialization error:', error);
|
||||
});
|
||||
|
||||
// Register event listeners for debugging translation loading
|
||||
i18n.on('initialized', (initialized) => {
|
||||
console.log('i18n initialized event:', initialized);
|
||||
console.log('Current language:', i18n.language);
|
||||
console.log('Available languages:', i18n.languages);
|
||||
console.log('Is initialized:', i18n.isInitialized);
|
||||
});
|
||||
|
||||
i18n.on('loaded', (loaded) => {
|
||||
console.log('Translations loaded event:', loaded);
|
||||
});
|
||||
|
||||
i18n.on('failedLoading', (lng, ns, msg) => {
|
||||
console.error(`Failed loading translation for ${lng}/${ns}: ${msg}`);
|
||||
});
|
||||
|
||||
i18n.on('missingKey', (lngs, namespace, key, res) => {
|
||||
console.warn(`Missing translation key: ${key} in namespace: ${namespace} for languages: ${lngs.join(', ')}`);
|
||||
});
|
||||
|
||||
// Create a custom event for language changes that components can listen for
|
||||
const dispatchLanguageChangeEvent = (lng: string) => {
|
||||
console.log(`Dispatching language change event for: ${lng}`);
|
||||
const event = new CustomEvent('app-language-changed', { detail: { language: lng } });
|
||||
window.dispatchEvent(event);
|
||||
};
|
||||
|
||||
i18n.on('languageChanged', (lng) => {
|
||||
console.log(`Language changed to: ${lng}`);
|
||||
|
||||
// Store language in localStorage for persistence
|
||||
localStorage.setItem('i18nextLng', lng);
|
||||
|
||||
// Update HTML lang attribute for accessibility and SEO
|
||||
document.documentElement.lang = lng;
|
||||
|
||||
const handleTranslationsLoaded = () => {
|
||||
// Dispatch a custom event after translations are loaded
|
||||
// This helps components know when to re-render
|
||||
dispatchLanguageChangeEvent(lng);
|
||||
|
||||
// Force update any i18next instances
|
||||
if (i18n.services && i18n.services.resourceStore) {
|
||||
// This triggers internal i18next change notifications
|
||||
const currentNS = i18n.options.defaultNS || 'translation';
|
||||
i18n.reloadResources(lng, currentNS);
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure translations are loaded when language changes
|
||||
if (!i18n.hasResourceBundle(lng, 'translation')) {
|
||||
console.log(`Loading translations for language ${lng}`);
|
||||
|
||||
const loadPath = isDevelopment
|
||||
? `./locales/${lng}/translation.json`
|
||||
: `/locales/${lng}/translation.json`;
|
||||
|
||||
fetch(loadPath)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
console.warn(`Failed to fetch translations for ${lng}: ${response.status}`);
|
||||
// Try alternative path
|
||||
return fetch(`/locales/${lng}/translation.json`);
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data) {
|
||||
console.log(`Successfully loaded translations for ${lng}`);
|
||||
i18n.addResourceBundle(lng, 'translation', data, true, true);
|
||||
|
||||
// After translations are loaded, dispatch the event
|
||||
handleTranslationsLoaded();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(`Error loading translations for ${lng}:`, err);
|
||||
// Even if loading fails, we should still dispatch event so UI updates
|
||||
handleTranslationsLoaded();
|
||||
});
|
||||
} else {
|
||||
console.log(`Translations for ${lng} already loaded, skipping fetch`);
|
||||
// If translations are already loaded, dispatch the event immediately
|
||||
handleTranslationsLoaded();
|
||||
}
|
||||
});
|
||||
|
||||
// Add a function to manually check translation availability
|
||||
// Add type declaration for the global function and custom events
|
||||
declare global {
|
||||
interface WindowEventMap {
|
||||
'app-language-changed': CustomEvent<{ language: string }>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
checkTranslation: (key: string) => void;
|
||||
forceLanguageReload: (lng?: string) => void;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose a function to manually check translations (helpful for debugging)
|
||||
window.checkTranslation = (key: string) => {
|
||||
try {
|
||||
const translation = i18n.t(key);
|
||||
console.log(`Translation for '${key}': ${translation}`);
|
||||
console.log(`Is key '${key}' available: ${translation !== key}`);
|
||||
return translation;
|
||||
} catch (error) {
|
||||
console.error(`Error checking translation for key '${key}':`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Add a global function to force language reload
|
||||
window.forceLanguageReload = (lng?: string) => {
|
||||
const targetLng = lng || i18n.language;
|
||||
console.log(`Force reloading language: ${targetLng}`);
|
||||
|
||||
// Force reload the resources for current language
|
||||
i18n.reloadResources(targetLng, 'translation')
|
||||
.then(() => {
|
||||
console.log(`Resources reloaded for ${targetLng}`);
|
||||
|
||||
// To guarantee a reload effect:
|
||||
// 1. First dispatch the event
|
||||
dispatchLanguageChangeEvent(targetLng);
|
||||
|
||||
// 2. Force i18next to refresh its cache and notify all components
|
||||
if (i18n.services && i18n.services.resourceStore) {
|
||||
Object.values(i18n.services.resourceStore.data).forEach(lang => {
|
||||
// Add a proper type guard to check if translation exists and is an object
|
||||
if (lang.translation && typeof lang.translation === 'object' && lang.translation !== null) {
|
||||
// Touch the translation object to ensure React detects changes
|
||||
const temp = {...lang.translation as Record<string, unknown>};
|
||||
lang.translation = temp;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Explicitly change language if needed
|
||||
if (lng) {
|
||||
setTimeout(() => {
|
||||
i18n.changeLanguage(targetLng);
|
||||
}, 50); // Small delay to ensure the DOM has time to update
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(`Error reloading resources: ${err}`);
|
||||
});
|
||||
};
|
||||
|
||||
export default i18n;
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { Area } from "../entities/Area";
|
||||
|
||||
export const fetchAreas = async (): Promise<Area[]> => {
|
||||
const response = await fetch("/api/areas?active=true");
|
||||
if (!response.ok) throw new Error('Failed to fetch areas.');
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const createArea = async (areaData: Partial<Area>): Promise<Area> => {
|
||||
const response = await fetch('/api/areas', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(areaData),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create area.');
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const updateArea = async (areaId: number, areaData: Partial<Area>): Promise<Area> => {
|
||||
const response = await fetch(`/api/areas/${areaId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(areaData),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update area.');
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const deleteArea = async (areaId: number): Promise<void> => {
|
||||
const response = await fetch(`/api/areas/${areaId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete area.');
|
||||
};
|
||||
|
|
@ -8,11 +8,11 @@ module AuthenticationHelper
|
|||
end
|
||||
|
||||
def require_login
|
||||
return if ['/login', '/logout', '/api/current_user'].include? request.path
|
||||
return if ['/api/login', '/api/logout', '/api/current_user'].include?(request.path_info)
|
||||
|
||||
return if logged_in?
|
||||
|
||||
if request.xhr? || request.path.start_with?('/api/')
|
||||
if request.xhr? || request.path_info.start_with?('/api/')
|
||||
halt 401, { error: 'You must be logged in' }.to_json
|
||||
else
|
||||
redirect '/login'
|
||||
|
|
|
|||
|
|
@ -5,13 +5,14 @@ class Sinatra::Application
|
|||
content_type :json
|
||||
|
||||
if logged_in?
|
||||
{ user: { email: current_user.email, id: current_user.id, language: current_user.language, appearance: current_user.appearance, timezone: current_user.timezone } }.to_json
|
||||
{ user: { email: current_user.email, id: current_user.id, language: current_user.language,
|
||||
appearance: current_user.appearance, timezone: current_user.timezone } }.to_json
|
||||
else
|
||||
{ user: nil }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
post '/login' do
|
||||
post '/api/login' do
|
||||
content_type :json
|
||||
request_payload = begin
|
||||
JSON.parse(request.body.read)
|
||||
|
|
@ -30,14 +31,19 @@ class Sinatra::Application
|
|||
if user&.authenticate(password)
|
||||
session[:user_id] = user.id
|
||||
status 200
|
||||
{ user: { email: user.email, id: user.id, language: user.language, appearance: user.appearance, timezone: user.timezone } }.to_json
|
||||
{ user: { email: user.email, id: user.id, language: user.language, appearance: user.appearance,
|
||||
timezone: user.timezone } }.to_json
|
||||
else
|
||||
halt 401, { errors: ['Invalid credentials'] }.to_json
|
||||
end
|
||||
end
|
||||
|
||||
get '/logout' do
|
||||
get '/api/logout' do
|
||||
session.clear
|
||||
redirect '/login'
|
||||
status 200
|
||||
{ message: 'Logged out successfully' }.to_json
|
||||
end
|
||||
# session.clear
|
||||
# redirect '/login'
|
||||
# end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Sinatra React App</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<base href="/">
|
||||
<title>Tududi</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="http://localhost:8080/js/bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>tududi</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<base href="/">
|
||||
<title>Tududi</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div> <!-- React will render here -->
|
||||
<script src="/js/bundle.js"></script> <!-- Load the React bundle -->
|
||||
<%= yield %>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
# https://curl.se/docs/http-cookies.html
|
||||
# This file was generated by libcurl! Edit at your own risk.
|
||||
|
||||
#HttpOnly_localhost FALSE / FALSE 1746192262 rack.session W3JvYS7y9hXUKdr6WsdMlrZaPVjCH0GAirPw%2Fmurx6MIJQm8e%2FHnISYGeYeEFrYXHvM52EbBaEatcQz7Fvd4%2F9VMWQvT5WrVrf1w%2F4Lb7abdHbwYJkQiK7o0L4rL%2Bj88ILPQ7ZY4fPqvl%2BFMeGsqO2VGJpwhE%2BU2XCKqBhFS81ejdBT%2BAHlWTzOeEzJ7ElC3Vo%2FBME%2BTEMddkC7lvkYQoWw1BoiRnLTrniQx1kWAb5pdBFf16RsuEBo9Z%2BSw1YryDdPUWfJnVLXT9szA9f45o9D%2Fsqo36VuniodyaDSS--xk78gHip2BjCI4ab--xgh8%2BzQm0bE%2BPLvdjTHLwg%3D%3D
|
||||
#HttpOnly_localhost FALSE / FALSE 1752327280 rack.session U6U152aP%2F1FvgHP%2BTqOI6ZFNAriX8YWVIlXFp6FFPuOc%2FBgNnbDCJdfBfkC6p6gVikrYfiVd17qZenOEAlYZFpDlg0nzPJZqCYxgc9bhu4o3QOdUrEEYLu5ryVLUxh7DdLd7OVrMt2yHk3wixuky0icIpTw5%2Bc4dxeh4Vt0cBvy4fQasw0FZfvAjbyaYCAlE7dqE5WZ5o1dT5xEbwCEH3JO14oPmta2xf%2Bx3PJjyvZCLh3Ipxm8%2F6qlCLUC0HNQa6NgRL7ak71bDj7e7NLQ%3D--rOzyGipe0E8%2BPtPC--lRaDcnZWXd1CIpbvnCLxJA%3D%3D
|
||||
|
|
|
|||
92
dist/frontend_components_Tasks_tsx.bcee0af8633c28243d32.js
vendored
Normal file
92
dist/frontend_components_Tasks_tsx.bcee0af8633c28243d32.js
vendored
Normal file
File diff suppressed because one or more lines are too long
12
dist/index.html
vendored
Normal file
12
dist/index.html
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<base href="/">
|
||||
<title>Tududi</title>
|
||||
<script defer src="/main.5904574f25ca7c8b87ba.js"></script></head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
1155
public/js/bundle.js → dist/main.5904574f25ca7c8b87ba.js
vendored
1155
public/js/bundle.js → dist/main.5904574f25ca7c8b87ba.js
vendored
File diff suppressed because one or more lines are too long
21
dist/vendors-node_modules_cross-fetch_dist_browser-ponyfill_js.6451a0808e985bae004f.js
vendored
Normal file
21
dist/vendors-node_modules_cross-fetch_dist_browser-ponyfill_js.6451a0808e985bae004f.js
vendored
Normal file
File diff suppressed because one or more lines are too long
197
frontend/App.tsx
Normal file
197
frontend/App.tsx
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import React, { useEffect, useState, Suspense, lazy } from "react";
|
||||
import {
|
||||
Routes,
|
||||
Route,
|
||||
Navigate,
|
||||
Outlet
|
||||
} from "react-router-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Login from "./components/Login";
|
||||
import NotFound from "./components/Shared/NotFound";
|
||||
import ProjectDetails from "./components/Project/ProjectDetails";
|
||||
import Projects from "./components/Projects";
|
||||
import AreaDetails from "./components/Area/AreaDetails";
|
||||
import Areas from "./components/Areas";
|
||||
import TagDetails from "./components/Tag/TagDetails";
|
||||
import Tags from "./components/Tags";
|
||||
import Notes from "./components/Notes";
|
||||
import NoteDetails from "./components/Note/NoteDetails";
|
||||
import ProfileSettings from "./components/Profile/ProfileSettings";
|
||||
import Layout from "./Layout";
|
||||
import { User } from "./entities/User";
|
||||
import TasksToday from "./components/Task/TasksToday";
|
||||
import LoadingScreen from "./components/Shared/LoadingScreen";
|
||||
import InboxItems from "./components/Inbox/InboxItems";
|
||||
// Lazy load Tasks component to prevent issues with tags loading
|
||||
const Tasks = lazy(() => import("./components/Tasks"));
|
||||
|
||||
const App: React.FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
if (!i18n.isInitialized) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/current_user", {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
console.log("User not authenticated, staying on current page");
|
||||
setCurrentUser(null);
|
||||
return;
|
||||
}
|
||||
throw new Error(`Failed to fetch user: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.user) {
|
||||
setCurrentUser(data.user);
|
||||
} else {
|
||||
console.log("No user data received, staying on current page");
|
||||
setCurrentUser(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch current user:", err);
|
||||
setCurrentUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch user on mount
|
||||
fetchCurrentUser();
|
||||
}, []);
|
||||
|
||||
// Listen for login events to update user state
|
||||
useEffect(() => {
|
||||
const handleUserLoggedIn = (event: CustomEvent) => {
|
||||
const user = event.detail;
|
||||
console.log('User logged in event received:', user);
|
||||
setCurrentUser(user);
|
||||
};
|
||||
|
||||
window.addEventListener('userLoggedIn', handleUserLoggedIn as EventListener);
|
||||
return () => window.removeEventListener('userLoggedIn', handleUserLoggedIn as EventListener);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (i18n.isInitialized) {
|
||||
fetch(`/locales/${i18n.language}/translation.json`)
|
||||
.then(response => {
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
i18n.addResourceBundle(i18n.language, 'translation', data, true, true);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Error manually fetching translation file:", error);
|
||||
});
|
||||
}
|
||||
}, [i18n.isInitialized]);
|
||||
|
||||
const [isDarkMode, setIsDarkMode] = useState<boolean>(() => {
|
||||
const storedPreference = localStorage.getItem("isDarkMode");
|
||||
return storedPreference !== null
|
||||
? storedPreference === "true"
|
||||
: window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
});
|
||||
|
||||
const toggleDarkMode = () => {
|
||||
const newValue = !isDarkMode;
|
||||
setIsDarkMode(newValue);
|
||||
localStorage.setItem("isDarkMode", JSON.stringify(newValue));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const updateTheme = () => {
|
||||
document.documentElement.classList.toggle("dark", isDarkMode);
|
||||
};
|
||||
updateTheme();
|
||||
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const mediaListener = (e: MediaQueryListEvent) => {
|
||||
if (!localStorage.getItem("isDarkMode")) {
|
||||
setIsDarkMode(e.matches);
|
||||
}
|
||||
};
|
||||
mediaQuery.addEventListener("change", mediaListener);
|
||||
return () => mediaQuery.removeEventListener("change", mediaListener);
|
||||
}, [isDarkMode]);
|
||||
|
||||
|
||||
const LoadingComponent = () => (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<div className="text-xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
{i18n.t('common.loading', 'Loading application... Please wait.')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingComponent />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LoadingComponent />}>
|
||||
<Routes>
|
||||
{currentUser ? (
|
||||
<>
|
||||
<Route
|
||||
element={
|
||||
<Layout
|
||||
currentUser={currentUser}
|
||||
setCurrentUser={setCurrentUser}
|
||||
isDarkMode={isDarkMode}
|
||||
toggleDarkMode={toggleDarkMode}
|
||||
>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/today" replace />} />
|
||||
<Route path="/today" element={<TasksToday />} />
|
||||
<Route
|
||||
path="/tasks"
|
||||
element={
|
||||
<Suspense fallback={<div className="p-4">{i18n.t('common.loading', 'Loading...')}</div>}>
|
||||
<Tasks />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route path="/inbox" element={<InboxItems />} />
|
||||
<Route path="/projects" element={<Projects />} />
|
||||
<Route path="/project/:id" element={<ProjectDetails />} />
|
||||
<Route path="/areas" element={<Areas />} />
|
||||
<Route path="/area/:id" element={<AreaDetails />} />
|
||||
<Route path="/tags" element={<Tags />} />
|
||||
<Route path="/tag/:id" element={<TagDetails />} />
|
||||
<Route path="/notes" element={<Notes />} />
|
||||
<Route path="/note/:id" element={<NoteDetails />} />
|
||||
<Route path="/profile" element={<ProfileSettings currentUser={currentUser} isDarkMode={isDarkMode} toggleDarkMode={toggleDarkMode} />} />
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Route>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<Navigate to="/login" replace />} />
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</>
|
||||
)}
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
|
@ -20,7 +20,8 @@ import { fetchNotes, createNote, updateNote } from "./utils/notesService";
|
|||
import { fetchAreas, createArea, updateArea } from "./utils/areasService";
|
||||
import { fetchTags, createTag, updateTag } from "./utils/tagsService";
|
||||
import { fetchProjects, createProject, updateProject } from "./utils/projectsService";
|
||||
import { fetchTasks, createTask, updateTask } from "./utils/tasksService";
|
||||
import { createTask, updateTask } from "./utils/tasksService";
|
||||
import { isAuthError } from "./utils/authUtils";
|
||||
|
||||
interface LayoutProps {
|
||||
currentUser: User;
|
||||
|
|
@ -69,8 +70,6 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
isError: isAreasError,
|
||||
},
|
||||
tasksStore: {
|
||||
tasks,
|
||||
setTasks,
|
||||
setLoading: setTasksLoading,
|
||||
setError: setTasksError,
|
||||
isLoading: isTasksLoading,
|
||||
|
|
@ -189,10 +188,15 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
await createNote(noteData);
|
||||
}
|
||||
loadNotes();
|
||||
} catch (error) {
|
||||
closeNoteModal();
|
||||
} catch (error: any) {
|
||||
console.error("Error saving note:", error);
|
||||
// Don't close modal if there's an auth error (user will be redirected)
|
||||
if (isAuthError(error)) {
|
||||
return;
|
||||
}
|
||||
closeNoteModal();
|
||||
}
|
||||
closeNoteModal();
|
||||
};
|
||||
|
||||
const handleSaveTask = async (taskData: Task) => {
|
||||
|
|
@ -202,12 +206,19 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
} else {
|
||||
await createTask(taskData);
|
||||
}
|
||||
const { tasks } = await fetchTasks();
|
||||
setTasks(tasks);
|
||||
} catch (error) {
|
||||
// Don't refetch all tasks here - let individual components handle their own state
|
||||
// This prevents unnecessary re-renders and race conditions
|
||||
closeTaskModal();
|
||||
} catch (error: any) {
|
||||
console.error("Error saving task:", error);
|
||||
// Don't close modal if there's an auth error (user will be redirected)
|
||||
if (isAuthError(error)) {
|
||||
return;
|
||||
}
|
||||
// For other errors, still close the modal but let the error bubble up
|
||||
closeTaskModal();
|
||||
throw error;
|
||||
}
|
||||
closeTaskModal();
|
||||
};
|
||||
|
||||
const handleCreateProject = async (name: string): Promise<Project> => {
|
||||
|
|
@ -232,10 +243,15 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
}
|
||||
const projectsData = await fetchProjects();
|
||||
setProjects(projectsData);
|
||||
} catch (error) {
|
||||
closeProjectModal();
|
||||
} catch (error: any) {
|
||||
console.error("Error saving project:", error);
|
||||
// Don't close modal if there's an auth error (user will be redirected)
|
||||
if (isAuthError(error)) {
|
||||
return;
|
||||
}
|
||||
closeProjectModal();
|
||||
}
|
||||
closeProjectModal();
|
||||
};
|
||||
|
||||
const handleSaveArea = async (areaData: Partial<Area>) => {
|
||||
|
|
@ -246,10 +262,15 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
await createArea(areaData);
|
||||
}
|
||||
loadAreas();
|
||||
} catch (error) {
|
||||
closeAreaModal();
|
||||
} catch (error: any) {
|
||||
console.error("Error saving area:", error);
|
||||
// Don't close modal if there's an auth error (user will be redirected)
|
||||
if (isAuthError(error)) {
|
||||
return;
|
||||
}
|
||||
closeAreaModal();
|
||||
}
|
||||
closeAreaModal();
|
||||
};
|
||||
|
||||
const handleSaveTag = async (tagData: Tag) => {
|
||||
|
|
@ -261,10 +282,32 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
}
|
||||
const tagsData = await fetchTags();
|
||||
setTags(tagsData);
|
||||
} catch (error) {
|
||||
closeTagModal();
|
||||
} catch (error: any) {
|
||||
console.error("Error saving tag:", error);
|
||||
// Don't close modal if there's an auth error (user will be redirected)
|
||||
if (isAuthError(error)) {
|
||||
return;
|
||||
}
|
||||
closeTagModal();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/logout', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setCurrentUser(null);
|
||||
} else {
|
||||
console.error('Logout failed:', await response.json());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during logout:', error);
|
||||
}
|
||||
closeTagModal();
|
||||
};
|
||||
|
||||
const mainContentMarginLeft = isSidebarOpen ? "ml-72" : "ml-0";
|
||||
|
|
@ -61,18 +61,14 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
// First close the dropdown
|
||||
setDropdownOpen(false);
|
||||
|
||||
// Use requestAnimationFrame for better timing than setTimeout
|
||||
// This ensures the DOM has updated before we trigger the modal open
|
||||
requestAnimationFrame(() => {
|
||||
// To better prevent flicker, wait one extra frame
|
||||
requestAnimationFrame(() => {
|
||||
if (item.id !== undefined) {
|
||||
openTaskModal(newTask, item.id);
|
||||
} else {
|
||||
openTaskModal(newTask);
|
||||
}
|
||||
});
|
||||
});
|
||||
// Use a simple timeout to ensure the dropdown closes before opening modal
|
||||
setTimeout(() => {
|
||||
if (item.id !== undefined) {
|
||||
openTaskModal(newTask, item.id);
|
||||
} else {
|
||||
openTaskModal(newTask);
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleConvertToProject = () => {
|
||||
|
|
@ -85,18 +81,14 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
// First close the dropdown
|
||||
setDropdownOpen(false);
|
||||
|
||||
// Use requestAnimationFrame for better timing than setTimeout
|
||||
// This ensures the DOM has updated before we trigger the modal open
|
||||
requestAnimationFrame(() => {
|
||||
// To better prevent flicker, wait one extra frame
|
||||
requestAnimationFrame(() => {
|
||||
if (item.id !== undefined) {
|
||||
openProjectModal(newProject, item.id);
|
||||
} else {
|
||||
openProjectModal(newProject);
|
||||
}
|
||||
});
|
||||
});
|
||||
// Use a simple timeout to ensure the dropdown closes before opening modal
|
||||
setTimeout(() => {
|
||||
if (item.id !== undefined) {
|
||||
openProjectModal(newProject, item.id);
|
||||
} else {
|
||||
openProjectModal(newProject);
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleConvertToNote = async () => {
|
||||
|
|
@ -136,18 +128,14 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
// First close the dropdown
|
||||
setDropdownOpen(false);
|
||||
|
||||
// Use requestAnimationFrame for better timing than setTimeout
|
||||
// This ensures the DOM has updated before we trigger the modal open
|
||||
requestAnimationFrame(() => {
|
||||
// To better prevent flicker, wait one extra frame
|
||||
requestAnimationFrame(() => {
|
||||
if (item.id !== undefined) {
|
||||
openNoteModal(newNote, item.id);
|
||||
} else {
|
||||
openNoteModal(newNote);
|
||||
}
|
||||
});
|
||||
});
|
||||
// Use a simple timeout to ensure the dropdown closes before opening modal
|
||||
setTimeout(() => {
|
||||
if (item.id !== undefined) {
|
||||
openNoteModal(newNote, item.id);
|
||||
} else {
|
||||
openNoteModal(newNote);
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const formattedDate = item.created_at
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { InboxItem } from '../../entities/InboxItem';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { Project } from '../../entities/Project';
|
||||
import { Note } from '../../entities/Note';
|
||||
|
|
@ -30,7 +29,7 @@ const InboxItems: React.FC = () => {
|
|||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
// Access store data
|
||||
const { inboxItems, isLoading, isError } = useStore(state => state.inboxStore);
|
||||
const { inboxItems, isLoading } = useStore(state => state.inboxStore);
|
||||
|
||||
// Modal states
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
|
|
@ -51,7 +50,6 @@ const InboxItems: React.FC = () => {
|
|||
|
||||
// Fetch projects for modals
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [areas, setAreas] = useState<any[]>([]);
|
||||
|
||||
// Wrapped in useCallback to prevent dependency issues in useEffect
|
||||
const refreshInboxItems = useCallback(() => {
|
||||
|
|
@ -128,7 +126,7 @@ const InboxItems: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleUpdateItem = async (id: number, content: string): Promise<void> => {
|
||||
const handleUpdateItem = async (id: number): Promise<void> => {
|
||||
// When edit button is clicked, we open the SimplifiedTaskModal instead of doing inline editing
|
||||
setItemToEdit(id);
|
||||
setIsEditModalOpen(true);
|
||||
|
|
@ -166,9 +164,7 @@ const InboxItems: React.FC = () => {
|
|||
setCurrentConversionItemId(inboxItemId);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
setIsTaskModalOpen(true);
|
||||
});
|
||||
setIsTaskModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenProjectModal = (project: Project | null, inboxItemId?: number) => {
|
||||
|
|
@ -178,9 +174,7 @@ const InboxItems: React.FC = () => {
|
|||
setCurrentConversionItemId(inboxItemId);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
setIsProjectModalOpen(true);
|
||||
});
|
||||
setIsProjectModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenNoteModal = (note: Note | null, inboxItemId?: number) => {
|
||||
|
|
@ -200,9 +194,7 @@ const InboxItems: React.FC = () => {
|
|||
setCurrentConversionItemId(inboxItemId);
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
setIsNoteModalOpen(true);
|
||||
});
|
||||
setIsNoteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveTask = async (task: Task) => {
|
||||
|
|
@ -331,16 +323,10 @@ const InboxItems: React.FC = () => {
|
|||
|
||||
{/* Task Modal - Always render it but control visibility with isOpen */}
|
||||
<TaskModal
|
||||
isOpen={isTaskModalOpen && taskToEdit !== null}
|
||||
isOpen={isTaskModalOpen}
|
||||
onClose={() => {
|
||||
// First set the modal to not open, then clear the task
|
||||
setIsTaskModalOpen(false);
|
||||
// Clear task data after modal is closed
|
||||
setTimeout(() => {
|
||||
if (!isTaskModalOpen) {
|
||||
setTaskToEdit(null);
|
||||
}
|
||||
}, 300); // Match the animation duration in TaskModal
|
||||
setTaskToEdit(null);
|
||||
}}
|
||||
task={taskToEdit || { name: '', status: 'not_started', priority: 'medium' }}
|
||||
onSave={handleSaveTask}
|
||||
|
|
@ -351,34 +337,22 @@ const InboxItems: React.FC = () => {
|
|||
|
||||
{/* Project Modal - Always render it but control visibility with isOpen */}
|
||||
<ProjectModal
|
||||
isOpen={isProjectModalOpen && projectToEdit !== null}
|
||||
isOpen={isProjectModalOpen}
|
||||
onClose={() => {
|
||||
// First set the modal to not open, then clear the project
|
||||
setIsProjectModalOpen(false);
|
||||
// Clear project data after modal is closed
|
||||
setTimeout(() => {
|
||||
if (!isProjectModalOpen) {
|
||||
setProjectToEdit(null);
|
||||
}
|
||||
}, 300); // Match the animation duration
|
||||
setProjectToEdit(null);
|
||||
}}
|
||||
onSave={handleSaveProject}
|
||||
project={projectToEdit || undefined}
|
||||
areas={areas}
|
||||
areas={[]}
|
||||
/>
|
||||
|
||||
{/* Note Modal - Always render it but control visibility with isOpen */}
|
||||
<NoteModal
|
||||
isOpen={isNoteModalOpen && noteToEdit !== null}
|
||||
isOpen={isNoteModalOpen}
|
||||
onClose={() => {
|
||||
// First set the modal to not open, then clear the note
|
||||
setIsNoteModalOpen(false);
|
||||
// Clear note data after modal is closed
|
||||
setTimeout(() => {
|
||||
if (!isNoteModalOpen) {
|
||||
setNoteToEdit(null);
|
||||
}
|
||||
}, 300); // Match the animation duration
|
||||
setNoteToEdit(null);
|
||||
}}
|
||||
onSave={handleSaveNote}
|
||||
note={noteToEdit || { title: '', content: '' }}
|
||||
|
|
@ -392,7 +366,7 @@ const InboxItems: React.FC = () => {
|
|||
setIsEditModalOpen(false);
|
||||
setItemToEdit(null);
|
||||
}}
|
||||
onSave={() => {}} // Not used in edit mode
|
||||
onSave={async () => {}} // Not used in edit mode
|
||||
initialText={inboxItems.find(item => item.id === itemToEdit)?.content || ""}
|
||||
editMode={true}
|
||||
onEdit={handleSaveEditedItem}
|
||||
0
frontend/components/Inbox/InboxNotification.tsx
Normal file
0
frontend/components/Inbox/InboxNotification.tsx
Normal file
|
|
@ -14,7 +14,7 @@ const Login: React.FC = () => {
|
|||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const response = await fetch('/login', {
|
||||
const response = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -34,6 +34,9 @@ const Login: React.FC = () => {
|
|||
console.log('Language changed to:', i18n.language);
|
||||
}
|
||||
|
||||
// Trigger a custom event to notify App component
|
||||
window.dispatchEvent(new CustomEvent('userLoggedIn', { detail: data.user }));
|
||||
|
||||
navigate('/today');
|
||||
} else {
|
||||
setError(data.errors[0] || 'Login failed. Please try again.');
|
||||
0
frontend/components/Metrics/ProjectMetricsCard.tsx
Normal file
0
frontend/components/Metrics/ProjectMetricsCard.tsx
Normal file
0
frontend/components/Metrics/TaskMetricsCard.tsx
Normal file
0
frontend/components/Metrics/TaskMetricsCard.tsx
Normal file
|
|
@ -49,19 +49,19 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
const response = await fetch("/logout", {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
const response = await fetch('/api/logout', {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setCurrentUser(null);
|
||||
navigate("/login");
|
||||
navigate('/login');
|
||||
} else {
|
||||
console.error("Failed to log out");
|
||||
console.error('Logout failed:', await response.json());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error logging out:", error);
|
||||
console.error('Error during logout:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -105,18 +105,25 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
)}
|
||||
</button>
|
||||
{isDropdownOpen && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg py-1 z-50">
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute right-4 top-16 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg py-1 border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<Link
|
||||
to="/profile"
|
||||
className="block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
onClick={() => setIsDropdownOpen(false)}
|
||||
>
|
||||
{t('navigation.profile')}
|
||||
{t('nav.profile', 'Profile Settings')}
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-left block px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
onClick={() => {
|
||||
setIsDropdownOpen(false);
|
||||
handleLogout();
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
{t('navigation.logout')}
|
||||
{t('nav.logout', 'Logout')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -6,6 +6,8 @@ import { useToast } from '../Shared/ToastContext';
|
|||
|
||||
interface ProfileSettingsProps {
|
||||
currentUser: { id: number; email: string };
|
||||
isDarkMode?: boolean;
|
||||
toggleDarkMode?: () => void;
|
||||
}
|
||||
|
||||
interface Profile {
|
||||
|
|
@ -61,14 +63,14 @@ const formatFrequency = (frequency: string): string => {
|
|||
* Displays and manages user profile settings including appearance, language,
|
||||
* timezone, telegram integration, and task summary settings.
|
||||
*/
|
||||
const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser }) => {
|
||||
const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser, isDarkMode, toggleDarkMode }) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
|
||||
// State variables
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<Profile>>({
|
||||
appearance: 'light',
|
||||
appearance: isDarkMode ? 'dark' : 'light',
|
||||
language: 'en',
|
||||
timezone: 'UTC',
|
||||
avatar_image: '',
|
||||
|
|
@ -147,6 +149,14 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser }) => {
|
|||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
|
||||
// Handle appearance change immediately
|
||||
if (name === 'appearance' && toggleDarkMode) {
|
||||
const shouldBeDark = value === 'dark';
|
||||
if (shouldBeDark !== isDarkMode) {
|
||||
toggleDarkMode();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle language change immediately
|
||||
if (name === 'language' && value !== i18n.language) {
|
||||
handleLanguageChange(value);
|
||||
|
|
@ -231,7 +241,7 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser }) => {
|
|||
const data = await response.json();
|
||||
setProfile(data);
|
||||
setFormData({
|
||||
appearance: data.appearance || 'light',
|
||||
appearance: isDarkMode ? 'dark' : 'light', // Use current app state instead of saved preference
|
||||
language: data.language || 'en',
|
||||
timezone: data.timezone || 'UTC',
|
||||
avatar_image: data.avatar_image || '',
|
||||
|
|
@ -284,6 +294,11 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser }) => {
|
|||
console.log(`Component refreshed with key: ${updateKey}, language: ${i18n.language}`);
|
||||
}, [updateKey, i18n.language]);
|
||||
|
||||
// Update appearance in form data when dark mode changes
|
||||
useEffect(() => {
|
||||
setFormData(prev => ({ ...prev, appearance: isDarkMode ? 'dark' : 'light' }));
|
||||
}, [isDarkMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleLanguageChanged = (lng: string) => {
|
||||
console.log(`Language changed to ${lng}`);
|
||||
|
|
@ -314,15 +329,6 @@ const ProfileSettings: React.FC<ProfileSettingsProps> = ({ currentUser }) => {
|
|||
};
|
||||
}, []);
|
||||
|
||||
const handleAvatarChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setFormData((prev) => ({ ...prev, avatar_image: reader.result as string }));
|
||||
};
|
||||
reader.readAsDataURL(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetupTelegram = async () => {
|
||||
setTelegramSetupStatus('loading');
|
||||
|
|
@ -16,6 +16,7 @@ import { PriorityType, Task } from "../../entities/Task";
|
|||
import { fetchProjectById, updateProject, deleteProject } from "../../utils/projectsService";
|
||||
import { createTask, updateTask, deleteTask } from "../../utils/tasksService";
|
||||
import { fetchAreas } from "../../utils/areasService";
|
||||
import { isAuthError } from "../../utils/authUtils";
|
||||
import { CalendarDaysIcon, InformationCircleIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
type PriorityStyles = Record<PriorityType, string> & { default: string };
|
||||
|
|
@ -67,7 +68,7 @@ const ProjectDetails: React.FC = () => {
|
|||
const handleTaskCreate = async (taskName: string) => {
|
||||
if (!project) {
|
||||
console.error("Cannot create task: Project is missing");
|
||||
return;
|
||||
throw new Error("Cannot create task: Project is missing");
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -77,8 +78,13 @@ const ProjectDetails: React.FC = () => {
|
|||
project_id: project.id,
|
||||
});
|
||||
setTasks((prevTasks) => [...prevTasks, newTask]);
|
||||
} catch (err) {
|
||||
} catch (err: any) {
|
||||
console.error("Error creating task:", err);
|
||||
// Check if it's an authentication error
|
||||
if (isAuthError(err)) {
|
||||
return;
|
||||
}
|
||||
throw err; // Re-throw to allow proper error handling by NewTask component
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ const CreateNewDropdownButton: React.FC<CreateNewDropdownButtonProps> = ({
|
|||
className="w-5 h-5 mr-2 text-gray-500 dark:text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t('dropdown.createNew')}
|
||||
{t('dropdown.createNew', 'Create New')}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className="w-5 h-5 text-gray-500 dark:text-gray-400"
|
||||
|
|
@ -96,7 +96,7 @@ const CreateNewDropdownButton: React.FC<CreateNewDropdownButtonProps> = ({
|
|||
role="menuitem"
|
||||
>
|
||||
{icon}
|
||||
{t(translationKey)}
|
||||
{t(translationKey, label)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
@ -21,20 +21,14 @@ const TagInput: React.FC<TagInputProps> = ({ initialTags, onTagsChange, availabl
|
|||
|
||||
// Update internal tags state when initialTags prop changes
|
||||
useEffect(() => {
|
||||
console.log("TagInput received initialTags:", initialTags);
|
||||
|
||||
// Set the tags state with the initial tags
|
||||
if (initialTags && initialTags.length > 0) {
|
||||
// Simply set our internal state to match the initialTags
|
||||
setTags(initialTags);
|
||||
console.log("Set tags to match initialTags:", initialTags);
|
||||
}
|
||||
}, [initialTags]);
|
||||
|
||||
// Clean up effect to notify parent when our tags state changes
|
||||
useEffect(() => {
|
||||
// Notify parent of current state
|
||||
console.log("TagInput internal tags state changed to:", tags);
|
||||
onTagsChange(tags);
|
||||
}, [tags, onTagsChange]);
|
||||
|
||||
|
|
@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import { PlusCircleIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface NewTaskProps {
|
||||
onTaskCreate: (taskName: string) => void;
|
||||
onTaskCreate: (taskName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
|
||||
|
|
@ -18,12 +18,15 @@ const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
|
|||
|
||||
const handleKeyDown = async (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' && taskName.trim()) {
|
||||
const taskText = taskName.trim();
|
||||
setTaskName('');
|
||||
|
||||
try {
|
||||
await onTaskCreate(taskName.trim());
|
||||
setTaskName('');
|
||||
await onTaskCreate(taskText);
|
||||
showSuccessToast(t('success.taskCreated', 'Task created successfully!'));
|
||||
} catch (error) {
|
||||
console.error('Error creating task:', error);
|
||||
setTaskName(taskText);
|
||||
showErrorToast(t('errors.taskCreate', 'Failed to create task.'));
|
||||
}
|
||||
}
|
||||
|
|
@ -4,11 +4,12 @@ import { InboxItem } from "../../entities/InboxItem";
|
|||
import { useToast } from "../Shared/ToastContext";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createInboxItemWithStore } from "../../utils/inboxService";
|
||||
import { isAuthError } from "../../utils/authUtils";
|
||||
|
||||
interface SimplifiedTaskModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (task: Task) => void;
|
||||
onSave: (task: Task) => Promise<void>;
|
||||
initialText?: string;
|
||||
editMode?: boolean;
|
||||
onEdit?: (text: string) => Promise<void>;
|
||||
|
|
@ -63,12 +64,21 @@ const SimplifiedTaskModal: React.FC<SimplifiedTaskModalProps> = ({
|
|||
status: "not_started",
|
||||
};
|
||||
|
||||
onSave(newTask);
|
||||
showSuccessToast(t('task.createSuccess'));
|
||||
setInputText('');
|
||||
try {
|
||||
await onSave(newTask);
|
||||
showSuccessToast(t('task.createSuccess'));
|
||||
setInputText('');
|
||||
handleClose();
|
||||
} catch (error: any) {
|
||||
// If it's an auth error, don't show error toast (user will be redirected)
|
||||
if (isAuthError(error)) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const newItem = await createInboxItemWithStore(inputText.trim());
|
||||
await createInboxItemWithStore(inputText.trim());
|
||||
|
||||
showSuccessToast(t('inbox.itemAdded'));
|
||||
|
||||
|
|
@ -86,6 +96,7 @@ const SimplifiedTaskModal: React.FC<SimplifiedTaskModalProps> = ({
|
|||
} else {
|
||||
showErrorToast(saveMode === 'task' ? t('task.createError') : t('inbox.addError'));
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [inputText, isSaving, editMode, onEdit, saveMode, onSave, showSuccessToast, showErrorToast, t, onClose]);
|
||||
|
|
@ -39,9 +39,10 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [localAvailableTags, setLocalAvailableTags] = useState<Array<{name: string}>>([]);
|
||||
const [tagsLoaded, setTagsLoaded] = useState(false);
|
||||
const [tagsLoading, setTagsLoading] = useState(false);
|
||||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const { tagsStore } = useStore();
|
||||
const { tags: availableTags, setTags: setAvailableTags, setLoading: setTagsLoading, setError: setTagsError } = tagsStore;
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -54,22 +55,26 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
|
||||
useEffect(() => {
|
||||
const loadTags = async () => {
|
||||
setTagsLoading(true);
|
||||
try {
|
||||
if (availableTags.length === 0) {
|
||||
if (isOpen && !tagsLoaded) {
|
||||
setTagsLoading(true);
|
||||
try {
|
||||
const fetchedTags = await fetchTags();
|
||||
setAvailableTags(fetchedTags);
|
||||
setLocalAvailableTags(fetchedTags);
|
||||
setTagsLoaded(true);
|
||||
} catch (error: any) {
|
||||
console.error("Error fetching tags:", error);
|
||||
setTagsLoaded(true); // Mark as loaded even on error to prevent retry loop
|
||||
} finally {
|
||||
setTagsLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
setTagsError(true);
|
||||
console.error("Error fetching tags:", error);
|
||||
} finally {
|
||||
setTagsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadTags();
|
||||
}, [availableTags.length, setAvailableTags, setTagsError, setTagsLoading]);
|
||||
// Only load tags if modal is open
|
||||
if (isOpen) {
|
||||
loadTags();
|
||||
}
|
||||
}, [isOpen, tagsLoaded]);
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
|
||||
|
|
@ -146,6 +151,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
setTimeout(() => {
|
||||
onClose();
|
||||
setIsClosing(false);
|
||||
setTagsLoaded(false); // Reset tags loaded state for next modal open
|
||||
}, 300);
|
||||
};
|
||||
|
||||
|
|
@ -220,7 +226,7 @@ const TaskModal: React.FC<TaskModalProps> = ({
|
|||
<TagInput
|
||||
onTagsChange={handleTagsChange}
|
||||
initialTags={formData.tags?.map((tag) => tag.name) || []}
|
||||
availableTags={availableTags}
|
||||
availableTags={localAvailableTags}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from "@heroicons/react/24/outline";
|
||||
import { fetchTasks, updateTask, deleteTask } from "../../utils/tasksService";
|
||||
import { fetchProjects } from "../../utils/projectsService";
|
||||
import { loadInboxItemsToStore } from "../../utils/inboxService";
|
||||
import { Task } from "../../entities/Task";
|
||||
import { useStore } from "../../store/useStore";
|
||||
import TaskList from "./TaskList";
|
||||
|
|
@ -73,6 +74,13 @@ const TasksToday: React.FC = () => {
|
|||
setIsLoading(true);
|
||||
setIsError(false);
|
||||
|
||||
try {
|
||||
// Load inbox items to ensure the notification appears correctly
|
||||
loadInboxItemsToStore();
|
||||
} catch (error) {
|
||||
console.error("Failed to load inbox items:", error);
|
||||
}
|
||||
|
||||
try {
|
||||
// Load projects first
|
||||
const projectsData = await fetchProjects();
|
||||
|
|
@ -7,6 +7,7 @@ import { Task } from "../entities/Task";
|
|||
import { Project } from "../entities/Project";
|
||||
import { getTitleAndIcon } from "./Task/getTitleAndIcon";
|
||||
import { getDescription } from "./Task/getDescription";
|
||||
import { createTask } from "../utils/tasksService";
|
||||
import {
|
||||
TagIcon,
|
||||
XMarkIcon,
|
||||
|
|
@ -68,9 +69,9 @@ const Tasks: React.FC = () => {
|
|||
navigate({
|
||||
pathname: location.pathname,
|
||||
search: `?${params.toString()}`,
|
||||
});
|
||||
}, { replace: true });
|
||||
}
|
||||
}, [location, navigate]);
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
|
|
@ -134,23 +135,13 @@ const Tasks: React.FC = () => {
|
|||
|
||||
const handleTaskCreate = async (taskData: Partial<Task>) => {
|
||||
try {
|
||||
const response = await fetch("/api/task", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(taskData),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const newTask = await response.json();
|
||||
setTasks((prevTasks) => [newTask, ...prevTasks]);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
console.error("Failed to create task:", errorData.error);
|
||||
setError("Failed to create task.");
|
||||
}
|
||||
const newTask = await createTask(taskData as Task);
|
||||
// Add the new task optimistically to avoid race conditions
|
||||
setTasks((prevTasks) => [newTask, ...prevTasks]);
|
||||
} catch (error) {
|
||||
console.error("Error creating task:", error);
|
||||
setError("Error creating task.");
|
||||
throw error; // Re-throw to allow proper error handling
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -206,7 +197,7 @@ const Tasks: React.FC = () => {
|
|||
navigate({
|
||||
pathname: location.pathname,
|
||||
search: `?${params.toString()}`,
|
||||
});
|
||||
}, { replace: true });
|
||||
setDropdownOpen(false);
|
||||
};
|
||||
|
||||
|
|
@ -314,8 +305,8 @@ const Tasks: React.FC = () => {
|
|||
{/* New Task Form */}
|
||||
{isNewTaskAllowed() && (
|
||||
<NewTask
|
||||
onTaskCreate={(taskName: string) =>
|
||||
handleTaskCreate({ name: taskName, status: "not_started" })
|
||||
onTaskCreate={async (taskName: string) =>
|
||||
await handleTaskCreate({ name: taskName, status: "not_started" })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
0
frontend/hooks/useTasksData.ts
Normal file
0
frontend/hooks/useTasksData.ts
Normal file
197
frontend/i18n.ts
Normal file
197
frontend/i18n.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import Backend from 'i18next-http-backend';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
const isDevelopment = process.env.NODE_ENV === 'development';
|
||||
|
||||
const fallbackResources = {
|
||||
en: {
|
||||
translation: {
|
||||
common: {
|
||||
loading: 'Loading...',
|
||||
appLoading: 'Loading application... Please wait.',
|
||||
error: 'Error',
|
||||
},
|
||||
auth: {
|
||||
login: 'Login',
|
||||
register: 'Register',
|
||||
},
|
||||
errors: {
|
||||
somethingWentWrong: 'Something went wrong, please try again',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const devResources = isDevelopment ? {
|
||||
en: {
|
||||
translation: fallbackResources.en.translation,
|
||||
},
|
||||
} : undefined;
|
||||
|
||||
const i18nInstance = i18n
|
||||
.use(Backend)
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next);
|
||||
|
||||
i18nInstance.init({
|
||||
fallbackLng: 'en',
|
||||
debug: isDevelopment,
|
||||
load: 'languageOnly',
|
||||
supportedLngs: ['en', 'es', 'el', 'jp', 'ua', 'de'],
|
||||
nonExplicitSupportedLngs: true,
|
||||
resources: devResources,
|
||||
detection: {
|
||||
order: ['querystring', 'cookie', 'localStorage', 'navigator'],
|
||||
lookupQuerystring: 'lng',
|
||||
lookupCookie: 'i18next',
|
||||
lookupLocalStorage: 'i18nextLng',
|
||||
caches: ['localStorage', 'cookie']
|
||||
},
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
defaultNS: 'translation',
|
||||
ns: ['translation'],
|
||||
backend: {
|
||||
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
||||
queryStringParams: { v: '1' },
|
||||
requestOptions: {
|
||||
cache: 'default',
|
||||
credentials: 'same-origin',
|
||||
mode: 'cors'
|
||||
}
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
const loadPath = isDevelopment ? `./locales/${i18n.language}/translation.json` : `/locales/${i18n.language}/translation.json`;
|
||||
|
||||
fetch(loadPath)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
if (isDevelopment) {
|
||||
return fetch(`/locales/${i18n.language}/translation.json`);
|
||||
}
|
||||
throw new Error(`Failed to fetch translation: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
i18n.addResourceBundle(i18n.language, 'translation', data, true, true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (isDevelopment) {
|
||||
try {
|
||||
setTimeout(() => {
|
||||
fetch(`/locales/${i18n.language}/translation.json`, {
|
||||
headers: { 'Accept': 'application/json' },
|
||||
mode: 'cors'
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
i18n.addResourceBundle(i18n.language, 'translation', data, true, true);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 1000);
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
i18n.on('initialized', () => {});
|
||||
i18n.on('loaded', () => {});
|
||||
i18n.on('failedLoading', () => {});
|
||||
i18n.on('missingKey', () => {});
|
||||
|
||||
const dispatchLanguageChangeEvent = (lng: string) => {
|
||||
const event = new CustomEvent('app-language-changed', { detail: { language: lng } });
|
||||
window.dispatchEvent(event);
|
||||
};
|
||||
|
||||
i18n.on('languageChanged', (lng) => {
|
||||
localStorage.setItem('i18nextLng', lng);
|
||||
document.documentElement.lang = lng;
|
||||
|
||||
const handleTranslationsLoaded = () => {
|
||||
dispatchLanguageChangeEvent(lng);
|
||||
|
||||
if (i18n.services && i18n.services.resourceStore) {
|
||||
const currentNS = i18n.options.defaultNS || 'translation';
|
||||
i18n.reloadResources(lng, currentNS);
|
||||
}
|
||||
};
|
||||
|
||||
if (!i18n.hasResourceBundle(lng, 'translation')) {
|
||||
const loadPath = isDevelopment
|
||||
? `./locales/${lng}/translation.json`
|
||||
: `/locales/${lng}/translation.json`;
|
||||
|
||||
fetch(loadPath)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return fetch(`/locales/${lng}/translation.json`);
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data) {
|
||||
i18n.addResourceBundle(lng, 'translation', data, true, true);
|
||||
handleTranslationsLoaded();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
handleTranslationsLoaded();
|
||||
});
|
||||
} else {
|
||||
handleTranslationsLoaded();
|
||||
}
|
||||
});
|
||||
|
||||
declare global {
|
||||
interface WindowEventMap {
|
||||
'app-language-changed': CustomEvent<{ language: string }>;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
checkTranslation: (key: string) => void;
|
||||
forceLanguageReload: (lng?: string) => void;
|
||||
}
|
||||
}
|
||||
|
||||
window.checkTranslation = (key: string) => {
|
||||
try {
|
||||
const translation = i18n.t(key);
|
||||
return translation;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
window.forceLanguageReload = (lng?: string) => {
|
||||
const targetLng = lng || i18n.language;
|
||||
|
||||
i18n.reloadResources(targetLng, 'translation')
|
||||
.then(() => {
|
||||
dispatchLanguageChangeEvent(targetLng);
|
||||
|
||||
if (i18n.services && i18n.services.resourceStore) {
|
||||
Object.values(i18n.services.resourceStore.data).forEach(lang => {
|
||||
if (lang.translation && typeof lang.translation === 'object' && lang.translation !== null) {
|
||||
const temp = {...lang.translation as Record<string, unknown>};
|
||||
lang.translation = temp;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (lng) {
|
||||
setTimeout(() => {
|
||||
i18n.changeLanguage(targetLng);
|
||||
}, 50);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
export default i18n;
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
/* ./app/frontend/styles/tailwind.css */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
55
frontend/utils/areasService.ts
Normal file
55
frontend/utils/areasService.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { Area } from "../entities/Area";
|
||||
import { handleAuthResponse } from "./authUtils";
|
||||
|
||||
export const fetchAreas = async (): Promise<Area[]> => {
|
||||
const response = await fetch("/api/areas?active=true", {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to fetch areas.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const createArea = async (areaData: Partial<Area>): Promise<Area> => {
|
||||
const response = await fetch('/api/areas', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(areaData),
|
||||
});
|
||||
|
||||
await handleAuthResponse(response, 'Failed to create area.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const updateArea = async (areaId: number, areaData: Partial<Area>): Promise<Area> => {
|
||||
const response = await fetch(`/api/areas/${areaId}`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(areaData),
|
||||
});
|
||||
|
||||
await handleAuthResponse(response, 'Failed to update area.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const deleteArea = async (areaId: number): Promise<void> => {
|
||||
const response = await fetch(`/api/areas/${areaId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
await handleAuthResponse(response, 'Failed to delete area.');
|
||||
};
|
||||
57
frontend/utils/authUtils.ts
Normal file
57
frontend/utils/authUtils.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* Get default headers for API requests including CSRF protection
|
||||
*/
|
||||
export const getDefaultHeaders = (): Record<string, string> => {
|
||||
return {
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Origin': window.location.origin,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get default headers for POST/PATCH requests
|
||||
*/
|
||||
export const getPostHeaders = (): Record<string, string> => {
|
||||
return {
|
||||
...getDefaultHeaders(),
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
};
|
||||
|
||||
// Global flag to prevent multiple simultaneous redirects
|
||||
let isRedirecting = false;
|
||||
|
||||
/**
|
||||
* Handles authentication errors by redirecting to login page
|
||||
* @param response - The fetch response object
|
||||
* @param errorMessage - Default error message to throw if not a 401
|
||||
* @returns Promise that resolves if response is ok, rejects with error if not
|
||||
*/
|
||||
export const handleAuthResponse = async (response: Response, errorMessage: string): Promise<Response> => {
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
// Check if we're already on the login page or already redirecting to avoid redirect loops
|
||||
if (window.location.pathname !== '/login' && !isRedirecting) {
|
||||
console.log('Authentication required, redirecting to login');
|
||||
isRedirecting = true;
|
||||
// Add a small delay to allow any pending operations to complete
|
||||
setTimeout(() => {
|
||||
window.location.href = '/login';
|
||||
}, 100);
|
||||
}
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if an error is an authentication error
|
||||
* @param error - The error to check
|
||||
* @returns true if it's an authentication error
|
||||
*/
|
||||
export const isAuthError = (error: any): boolean => {
|
||||
return error?.message && error.message.includes('Authentication required');
|
||||
};
|
||||
|
|
@ -1,11 +1,17 @@
|
|||
import { InboxItem } from "../entities/InboxItem";
|
||||
import { useStore } from "../store/useStore";
|
||||
import { handleAuthResponse } from "./authUtils";
|
||||
|
||||
// API functions
|
||||
export const fetchInboxItems = async (): Promise<InboxItem[]> => {
|
||||
const response = await fetch('/api/inbox');
|
||||
const response = await fetch('/api/inbox', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch inbox items.');
|
||||
await handleAuthResponse(response, 'Failed to fetch inbox items.');
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
|
|
@ -19,43 +25,56 @@ export const fetchInboxItems = async (): Promise<InboxItem[]> => {
|
|||
export const createInboxItem = async (content: string, source: string = 'tududi'): Promise<InboxItem> => {
|
||||
const response = await fetch('/api/inbox', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ content, source }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create inbox item.');
|
||||
|
||||
await handleAuthResponse(response, 'Failed to create inbox item.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const updateInboxItem = async (itemId: number, content: string): Promise<InboxItem> => {
|
||||
const response = await fetch(`/api/inbox/${itemId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update inbox item.');
|
||||
|
||||
await handleAuthResponse(response, 'Failed to update inbox item.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const processInboxItem = async (itemId: number): Promise<InboxItem> => {
|
||||
const response = await fetch(`/api/inbox/${itemId}/process`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to process inbox item.');
|
||||
|
||||
await handleAuthResponse(response, 'Failed to process inbox item.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const deleteInboxItem = async (itemId: number): Promise<void> => {
|
||||
const response = await fetch(`/api/inbox/${itemId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete inbox item.');
|
||||
await handleAuthResponse(response, 'Failed to delete inbox item.');
|
||||
};
|
||||
|
||||
// Track last check time to detect new items
|
||||
|
|
@ -1,8 +1,12 @@
|
|||
import { Note } from "../entities/Note";
|
||||
import { handleAuthResponse, getDefaultHeaders, getPostHeaders } from "./authUtils";
|
||||
|
||||
export const fetchNotes = async (): Promise<Note[]> => {
|
||||
const response = await fetch("/api/notes");
|
||||
if (!response.ok) throw new Error('Failed to fetch notes.');
|
||||
const response = await fetch("/api/notes", {
|
||||
credentials: 'include',
|
||||
headers: getDefaultHeaders(),
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to fetch notes.');
|
||||
|
||||
return await response.json();
|
||||
};
|
||||
|
|
@ -12,16 +16,12 @@ export const createNote = async (noteData: Note): Promise<Note> => {
|
|||
console.log("Creating note with data:", JSON.stringify(noteData, null, 2));
|
||||
const response = await fetch('/api/note', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
headers: getPostHeaders(),
|
||||
body: JSON.stringify(noteData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
console.error("Error creating note:", errorData);
|
||||
throw new Error(`Failed to create note: ${JSON.stringify(errorData)}`);
|
||||
}
|
||||
|
||||
await handleAuthResponse(response, 'Failed to create note.');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Exception in createNote:", error);
|
||||
|
|
@ -32,19 +32,21 @@ export const createNote = async (noteData: Note): Promise<Note> => {
|
|||
export const updateNote = async (noteId: number, noteData: Note): Promise<Note> => {
|
||||
const response = await fetch(`/api/note/${noteId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
headers: getPostHeaders(),
|
||||
body: JSON.stringify(noteData),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update note.');
|
||||
|
||||
await handleAuthResponse(response, 'Failed to update note.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const deleteNote = async (noteId: number): Promise<void> => {
|
||||
const response = await fetch(`/api/note/${noteId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: getDefaultHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete note.');
|
||||
await handleAuthResponse(response, 'Failed to delete note.');
|
||||
};
|
||||
172
frontend/utils/profileService.ts
Normal file
172
frontend/utils/profileService.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { handleAuthResponse } from "./authUtils";
|
||||
|
||||
interface Profile {
|
||||
id: number;
|
||||
email: string;
|
||||
appearance: 'light' | 'dark';
|
||||
language: string;
|
||||
timezone: string;
|
||||
avatar_image: string | null;
|
||||
telegram_bot_token: string | null;
|
||||
telegram_chat_id: string | null;
|
||||
task_summary_enabled: boolean;
|
||||
task_summary_frequency: string;
|
||||
}
|
||||
|
||||
interface SchedulerStatus {
|
||||
success: boolean;
|
||||
enabled: boolean;
|
||||
frequency: string;
|
||||
last_run: string | null;
|
||||
next_run: string | null;
|
||||
}
|
||||
|
||||
interface TelegramBotInfo {
|
||||
username: string;
|
||||
polling_status: any;
|
||||
chat_url: string;
|
||||
}
|
||||
|
||||
export const fetchProfile = async (): Promise<Profile> => {
|
||||
const response = await fetch('/api/profile', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to fetch profile data.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const updateProfile = async (profileData: Partial<Profile>): Promise<Profile> => {
|
||||
const response = await fetch('/api/profile', {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(profileData),
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to update profile.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const fetchSchedulerStatus = async (): Promise<SchedulerStatus> => {
|
||||
const response = await fetch('/api/profile/task-summary/status', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to fetch scheduler status.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const sendTaskSummaryNow = async (): Promise<any> => {
|
||||
const response = await fetch('/api/profile/task-summary/send-now', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to send task summary.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const fetchTelegramPollingStatus = async (): Promise<any> => {
|
||||
const response = await fetch('/api/telegram/polling-status', {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to fetch polling status.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const setupTelegram = async (botToken: string, chatId: string): Promise<TelegramBotInfo> => {
|
||||
const response = await fetch('/api/telegram/setup', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
bot_token: botToken,
|
||||
chat_id: chatId,
|
||||
}),
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to setup telegram.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const startTelegramPolling = async (): Promise<any> => {
|
||||
const response = await fetch('/api/telegram/start-polling', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to start telegram polling.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const stopTelegramPolling = async (): Promise<any> => {
|
||||
const response = await fetch('/api/telegram/stop-polling', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to stop telegram polling.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const testTelegram = async (userId: number, message: string): Promise<any> => {
|
||||
const response = await fetch(`/api/telegram/test/${userId}`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ text: message }),
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to send test message.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const toggleTaskSummary = async (): Promise<any> => {
|
||||
const response = await fetch('/api/profile/task-summary/toggle', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to toggle task summary.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const updateTaskSummaryFrequency = async (frequency: string): Promise<any> => {
|
||||
const response = await fetch('/api/profile/task-summary/frequency', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ frequency }),
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to update task summary frequency.');
|
||||
return await response.json();
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { Project } from "../entities/Project";
|
||||
import { handleAuthResponse } from "./authUtils";
|
||||
|
||||
export const fetchProjects = async (activeFilter = "all", areaFilter = ""): Promise<Project[]> => {
|
||||
let url = `/api/projects`;
|
||||
|
|
@ -13,7 +14,7 @@ export const fetchProjects = async (activeFilter = "all", areaFilter = ""): Prom
|
|||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch projects.');
|
||||
await handleAuthResponse(response, 'Failed to fetch projects.');
|
||||
|
||||
const data = await response.json();
|
||||
return data.projects || data;
|
||||
|
|
@ -25,39 +26,48 @@ export const fetchProjectById = async (projectId: string): Promise<Project> => {
|
|||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch project details.');
|
||||
|
||||
await handleAuthResponse(response, 'Failed to fetch project details.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const createProject = async (projectData: Partial<Project>): Promise<Project> => {
|
||||
const response = await fetch('/api/project', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(projectData),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create project.');
|
||||
|
||||
await handleAuthResponse(response, 'Failed to create project.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const updateProject = async (projectId: number, projectData: Partial<Project>): Promise<Project> => {
|
||||
const response = await fetch(`/api/project/${projectId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(projectData),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update project.');
|
||||
|
||||
await handleAuthResponse(response, 'Failed to update project.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const deleteProject = async (projectId: number): Promise<void> => {
|
||||
const response = await fetch(`/api/project/${projectId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete project.');
|
||||
await handleAuthResponse(response, 'Failed to delete project.');
|
||||
};
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { Tag } from "../entities/Tag";
|
||||
import { handleAuthResponse } from "./authUtils";
|
||||
|
||||
export const fetchTags = async (): Promise<Tag[]> => {
|
||||
try {
|
||||
|
|
@ -10,8 +11,7 @@ export const fetchTags = async (): Promise<Tag[]> => {
|
|||
'Pragma': 'no-cache'
|
||||
}
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch tags.');
|
||||
|
||||
await handleAuthResponse(response, 'Failed to fetch tags.');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error("Tags fetch error:", error);
|
||||
|
|
@ -23,31 +23,41 @@ export const fetchTags = async (): Promise<Tag[]> => {
|
|||
export const createTag = async (tagData: Tag): Promise<Tag> => {
|
||||
const response = await fetch('/api/tag', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(tagData),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create tag.');
|
||||
|
||||
await handleAuthResponse(response, 'Failed to create tag.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const updateTag = async (tagId: number, tagData: Tag): Promise<Tag> => {
|
||||
const response = await fetch(`/api/tag/${tagId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(tagData),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update tag.');
|
||||
|
||||
await handleAuthResponse(response, 'Failed to update tag.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const deleteTag = async (tagId: number): Promise<void> => {
|
||||
const response = await fetch(`/api/tag/${tagId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete tag.');
|
||||
await handleAuthResponse(response, 'Failed to delete tag.');
|
||||
};
|
||||
|
|
@ -1,10 +1,13 @@
|
|||
import { Metrics } from "../entities/Metrics";
|
||||
import { Task } from "../entities/Task";
|
||||
import { handleAuthResponse, getDefaultHeaders, getPostHeaders } from "./authUtils";
|
||||
|
||||
export const fetchTasks = async (query = ''): Promise<{ tasks: Task[]; metrics: Metrics }> => {
|
||||
const response = await fetch(`/api/tasks${query}`);
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch tasks.');
|
||||
const response = await fetch(`/api/tasks${query}`, {
|
||||
credentials: 'include',
|
||||
headers: getDefaultHeaders(),
|
||||
});
|
||||
await handleAuthResponse(response, 'Failed to fetch tasks.');
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
|
|
@ -22,31 +25,33 @@ export const fetchTasks = async (query = ''): Promise<{ tasks: Task[]; metrics:
|
|||
export const createTask = async (taskData: Task): Promise<Task> => {
|
||||
const response = await fetch('/api/task', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
headers: getPostHeaders(),
|
||||
body: JSON.stringify(taskData),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to create task.');
|
||||
|
||||
await handleAuthResponse(response, 'Failed to create task.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const updateTask = async (taskId: number, taskData: Task): Promise<Task> => {
|
||||
const response = await fetch(`/api/task/${taskId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
headers: getPostHeaders(),
|
||||
body: JSON.stringify(taskData),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to update task.');
|
||||
|
||||
await handleAuthResponse(response, 'Failed to update task.');
|
||||
return await response.json();
|
||||
};
|
||||
|
||||
export const deleteTask = async (taskId: number): Promise<void> => {
|
||||
const response = await fetch(`/api/task/${taskId}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: getDefaultHeaders(),
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete task.');
|
||||
await handleAuthResponse(response, 'Failed to delete task.');
|
||||
};
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
/**
|
||||
* Service for URL-related operations like extracting titles from web pages
|
||||
*/
|
||||
import { handleAuthResponse } from "./authUtils";
|
||||
|
||||
export interface UrlTitleResult {
|
||||
url: string;
|
||||
|
|
@ -16,12 +17,14 @@ export interface UrlTitleResult {
|
|||
*/
|
||||
export const extractUrlTitle = async (url: string): Promise<UrlTitleResult> => {
|
||||
try {
|
||||
const response = await fetch(`/api/url/title?url=${encodeURIComponent(url)}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to extract URL title');
|
||||
}
|
||||
const response = await fetch(`/api/url/title?url=${encodeURIComponent(url)}`, {
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
await handleAuthResponse(response, 'Failed to extract URL title');
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error extracting URL title:', error);
|
||||
|
|
@ -38,14 +41,15 @@ export const extractTitleFromText = async (text: string): Promise<UrlTitleResult
|
|||
try {
|
||||
const response = await fetch('/api/url/extract-from-text', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to extract title from text');
|
||||
}
|
||||
|
||||
await handleAuthResponse(response, 'Failed to extract title from text');
|
||||
const result = await response.json();
|
||||
|
||||
if (result.found === false) {
|
||||
388
package-lock.json
generated
388
package-lock.json
generated
|
|
@ -42,6 +42,7 @@
|
|||
"eslint": "^9.13.0",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"globals": "^15.11.0",
|
||||
"html-webpack-plugin": "^5.6.3",
|
||||
"postcss": "^8.4.47",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"react-refresh": "^0.14.2",
|
||||
|
|
@ -2500,6 +2501,13 @@
|
|||
"integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/html-minifier-terser": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
|
||||
"integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/http-errors": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
|
||||
|
|
@ -3656,6 +3664,13 @@
|
|||
"multicast-dns": "^7.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
|
|
@ -3766,6 +3781,17 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camel-case": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz",
|
||||
"integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pascal-case": "^3.1.2",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-css": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
|
|
@ -3861,6 +3887,29 @@
|
|||
"node": ">=6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/clean-css": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz",
|
||||
"integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"source-map": "~0.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/clean-css/node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/client-only": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
||||
|
|
@ -4155,6 +4204,23 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/css-select": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz",
|
||||
"integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0",
|
||||
"css-what": "^6.0.1",
|
||||
"domhandler": "^4.3.1",
|
||||
"domutils": "^2.8.0",
|
||||
"nth-check": "^2.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/css-to-react-native": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
|
||||
|
|
@ -4166,6 +4232,19 @@
|
|||
"postcss-value-parser": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/css-what": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz",
|
||||
"integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
|
|
@ -4401,6 +4480,86 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-converter": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
|
||||
"integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"utila": "~0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/dom-serializer": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz",
|
||||
"integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.0.1",
|
||||
"domhandler": "^4.2.0",
|
||||
"entities": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domelementtype": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/domhandler": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz",
|
||||
"integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/domutils": {
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
|
||||
"integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^1.0.1",
|
||||
"domelementtype": "^2.2.0",
|
||||
"domhandler": "^4.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/dot-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
|
||||
"integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"no-case": "^3.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/eastasianwidth": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||
|
|
@ -4455,6 +4614,16 @@
|
|||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
|
||||
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
|
||||
|
|
@ -5842,6 +6011,16 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
|
|
@ -5915,6 +6094,38 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"node_modules/html-minifier-terser": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
|
||||
"integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"camel-case": "^4.1.2",
|
||||
"clean-css": "^5.2.2",
|
||||
"commander": "^8.3.0",
|
||||
"he": "^1.2.0",
|
||||
"param-case": "^3.0.4",
|
||||
"relateurl": "^0.2.7",
|
||||
"terser": "^5.10.0"
|
||||
},
|
||||
"bin": {
|
||||
"html-minifier-terser": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/html-minifier-terser/node_modules/commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
|
|
@ -5923,6 +6134,59 @@
|
|||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-webpack-plugin": {
|
||||
"version": "5.6.3",
|
||||
"resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.3.tgz",
|
||||
"integrity": "sha512-QSf1yjtSAsmf7rYBV7XX86uua4W/vkhIt0xNXKbsi2foEeW7vjJQz4bhnpL3xH+l1ryl1680uNv968Z+X6jSYg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/html-minifier-terser": "^6.0.0",
|
||||
"html-minifier-terser": "^6.0.2",
|
||||
"lodash": "^4.17.21",
|
||||
"pretty-error": "^4.0.0",
|
||||
"tapable": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/html-webpack-plugin"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rspack/core": "0.x || 1.x",
|
||||
"webpack": "^5.20.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@rspack/core": {
|
||||
"optional": true
|
||||
},
|
||||
"webpack": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz",
|
||||
"integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fb55"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.0.1",
|
||||
"domhandler": "^4.0.0",
|
||||
"domutils": "^2.5.2",
|
||||
"entities": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/http-deceiver": {
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz",
|
||||
|
|
@ -6881,8 +7145,7 @@
|
|||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"peer": true
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"node_modules/lodash.debounce": {
|
||||
"version": "4.0.8",
|
||||
|
|
@ -6907,6 +7170,16 @@
|
|||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lower-case": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
|
||||
"integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
|
|
@ -7120,6 +7393,17 @@
|
|||
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/no-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
|
||||
"integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lower-case": "^2.0.2",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
|
|
@ -7171,6 +7455,19 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nth-check": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
|
||||
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"boolbase": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
|
|
@ -7405,6 +7702,17 @@
|
|||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/param-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
|
||||
"integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dot-case": "^3.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/parent-module": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
|
|
@ -7444,6 +7752,17 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/pascal-case": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz",
|
||||
"integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"no-case": "^3.0.4",
|
||||
"tslib": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
|
|
@ -7816,6 +8135,17 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-error": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz",
|
||||
"integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lodash": "^4.17.20",
|
||||
"renderkid": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
|
|
@ -8235,6 +8565,53 @@
|
|||
"regjsparser": "bin/parser"
|
||||
}
|
||||
},
|
||||
"node_modules/relateurl": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz",
|
||||
"integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/renderkid": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz",
|
||||
"integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-select": "^4.1.3",
|
||||
"dom-converter": "^0.2.0",
|
||||
"htmlparser2": "^6.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"strip-ansi": "^6.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/renderkid/node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/renderkid/node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
|
|
@ -9727,6 +10104,13 @@
|
|||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/utila": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz",
|
||||
"integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
"build": "tsc --noEmit && webpack --config webpack.config.js",
|
||||
"start": "tsc --noEmit && webpack serve --config webpack.config.js",
|
||||
"dev": "webpack serve --config webpack.config.js --hot",
|
||||
"lint": "eslint 'app/frontend/**/*.{js,jsx,ts,tsx}'"
|
||||
"lint": "eslint 'frontend/**/*.{js,jsx,ts,tsx}'"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
|
@ -34,6 +34,7 @@
|
|||
"eslint": "^9.13.0",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"globals": "^15.11.0",
|
||||
"html-webpack-plugin": "^5.6.3",
|
||||
"postcss": "^8.4.47",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"react-refresh": "^0.14.2",
|
||||
|
|
|
|||
12
public/index.html
Normal file
12
public/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<base href="/">
|
||||
<title>Tududi</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue