Compare commits
3 Commits
160d6014df
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 91131302e6 | |||
| 5a4d7e0ba9 | |||
| 1087da5fd8 |
@@ -42,8 +42,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Deploy to Dokploy
|
- name: Deploy to Dokploy
|
||||||
run: |
|
run: |
|
||||||
curl -fsSL -X POST "${{ secrets.DOKPLOY_URL }}/api/application.deploy" \
|
curl -fsSL -X POST "${{ secrets.DOKPLOY_URL }}/api/compose.deploy" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "x-api-key: ${{ secrets.DOKPLOY_API_TOKEN }}" \
|
-H "x-api-key: ${{ secrets.DOKPLOY_API_TOKEN }}" \
|
||||||
-d '{"applicationId": "${{ secrets.DOKPLOY_APP_ID }}"}'
|
-d '{"composeId": "${{ secrets.DOKPLOY_COMPOSE_ID }}"}'
|
||||||
echo "Deploy triggered on Dokploy"
|
echo "Deploy triggered on Dokploy"
|
||||||
|
|||||||
2
dist/index.html
vendored
2
dist/index.html
vendored
@@ -6,7 +6,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Todo App - Task management made simple" />
|
<meta name="description" content="Todo App - Task management made simple" />
|
||||||
<title>Todo App</title>
|
<title>Todo App</title>
|
||||||
<script type="module" crossorigin src="/assets/index-BYeSixg2.js"></script>
|
<script type="module" crossorigin src="/assets/index-BJQCGGBb.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-C44jgAFE.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-C44jgAFE.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Plus, Calendar, Flag, Tag, X } from 'lucide-react';
|
import { Plus, Calendar, X } from 'lucide-react';
|
||||||
import type { Priority } from '@/types';
|
import type { Priority } from '@/types';
|
||||||
import { cn, getPriorityColor } from '@/lib/utils';
|
import { cn, getPriorityColor } from '@/lib/utils';
|
||||||
import { useTasksStore } from '@/stores/tasks';
|
import { useTasksStore } from '@/stores/tasks';
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { ChevronRight, ChevronDown, Check } from 'lucide-react';
|
import { ChevronRight, ChevronDown, Check } from 'lucide-react';
|
||||||
import type { Task } from '@/types';
|
import type { Task } from '@/types';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { useTasksStore } from '@/stores/tasks';
|
import { useTasksStore } from '@/stores/tasks';
|
||||||
|
|
||||||
interface CompletedSectionProps {
|
interface CompletedSectionProps {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from 'react';
|
|||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Inbox, Calendar, CalendarDays, Plus, ChevronDown, ChevronRight,
|
Inbox, Calendar, CalendarDays, Plus, ChevronDown, ChevronRight,
|
||||||
Hash, Settings, LogOut, User, FolderPlus, Tag, X, Check,
|
Settings, LogOut, Tag, X,
|
||||||
PanelLeftClose, PanelLeftOpen
|
PanelLeftClose, PanelLeftOpen
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -56,7 +56,7 @@ export function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
setIsCollapsed(next);
|
setIsCollapsed(next);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next));
|
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next));
|
||||||
} catch {}
|
} catch { /* ignore localStorage errors */ }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNavClick = () => {
|
const handleNavClick = () => {
|
||||||
@@ -94,7 +94,6 @@ export function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
navigate('/login');
|
navigate('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
const inbox = projects.find(p => p.isInbox);
|
|
||||||
const regularProjects = projects.filter(p => !p.isInbox);
|
const regularProjects = projects.filter(p => !p.isInbox);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
Tag, MessageSquare, ChevronRight, AlertCircle, Layers
|
Tag, MessageSquare, ChevronRight, AlertCircle, Layers
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { Task, Priority, Comment, Section } from '@/types';
|
import type { Task, Priority, Comment, Section } from '@/types';
|
||||||
import { cn, formatDate, getPriorityColor, getPriorityLabel } from '@/lib/utils';
|
import { cn, getPriorityColor } from '@/lib/utils';
|
||||||
import { useTasksStore } from '@/stores/tasks';
|
import { useTasksStore } from '@/stores/tasks';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
@@ -29,7 +29,9 @@ export function TaskDetail({ task, onClose }: TaskDetailProps) {
|
|||||||
const titleRef = useRef<HTMLInputElement>(null);
|
const titleRef = useRef<HTMLInputElement>(null);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Sync local state from task prop - intentional setState in effect for controlled form
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- syncing local form state from prop
|
||||||
setTitle(task.title);
|
setTitle(task.title);
|
||||||
setDescription(task.description || '');
|
setDescription(task.description || '');
|
||||||
setDueDate(task.dueDate ? task.dueDate.slice(0, 10) : '');
|
setDueDate(task.dueDate ? task.dueDate.slice(0, 10) : '');
|
||||||
@@ -50,7 +52,7 @@ export function TaskDetail({ task, onClose }: TaskDetailProps) {
|
|||||||
if (projectId) {
|
if (projectId) {
|
||||||
const proj = projects.find(p => p.id === projectId);
|
const proj = projects.find(p => p.id === projectId);
|
||||||
if (proj?.sections) {
|
if (proj?.sections) {
|
||||||
setAvailableSections(proj.sections);
|
setAvailableSections(proj.sections); // eslint-disable-line react-hooks/set-state-in-effect
|
||||||
} else {
|
} else {
|
||||||
api.getProject(projectId).then((p) => {
|
api.getProject(projectId).then((p) => {
|
||||||
setAvailableSections(p.sections || []);
|
setAvailableSections(p.sections || []);
|
||||||
|
|||||||
@@ -1,25 +1,23 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import type { Mock } from 'vitest';
|
||||||
|
|
||||||
// We need to test the ApiClient class, so we import the module fresh
|
// We need to test the ApiClient class, so we import the module fresh
|
||||||
// The api.ts uses import.meta.env.PROD which is false in test
|
// The api.ts uses import.meta.env.PROD which is false in test
|
||||||
// So API_BASE = '/api' and AUTH_BASE = ''
|
// So API_BASE = '/api' and AUTH_BASE = ''
|
||||||
|
|
||||||
let ApiClient: any;
|
let api: Record<string, (...args: unknown[]) => unknown>;
|
||||||
let api: any;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
vi.stubGlobal('fetch', vi.fn());
|
vi.stubGlobal('fetch', vi.fn());
|
||||||
const mod = await import('@/lib/api');
|
const mod = await import('@/lib/api');
|
||||||
api = mod.api;
|
api = mod.api as unknown as Record<string, (...args: unknown[]) => unknown>;
|
||||||
// Get the class from the singleton
|
|
||||||
ApiClient = (api as any).constructor;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function mockFetchResponse(data: any, ok = true, status = 200) {
|
function mockFetchResponse(data: unknown, ok = true, _status = 200) {
|
||||||
(fetch as any).mockResolvedValueOnce({
|
(fetch as Mock).mockResolvedValueOnce({
|
||||||
ok,
|
ok,
|
||||||
status,
|
status: _status,
|
||||||
json: () => Promise.resolve(data),
|
json: () => Promise.resolve(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -42,7 +40,7 @@ describe('ApiClient', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('throws on failed login', async () => {
|
it('throws on failed login', async () => {
|
||||||
(fetch as any).mockResolvedValueOnce({
|
(fetch as Mock).mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: false,
|
||||||
json: () => Promise.resolve({ message: 'Invalid credentials' }),
|
json: () => Promise.resolve({ message: 'Invalid credentials' }),
|
||||||
});
|
});
|
||||||
@@ -78,7 +76,7 @@ describe('ApiClient', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns null when not authenticated', async () => {
|
it('returns null when not authenticated', async () => {
|
||||||
(fetch as any).mockResolvedValueOnce({
|
(fetch as Mock).mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: false,
|
||||||
json: () => Promise.resolve({}),
|
json: () => Promise.resolve({}),
|
||||||
});
|
});
|
||||||
@@ -88,7 +86,7 @@ describe('ApiClient', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns null on network error', async () => {
|
it('returns null on network error', async () => {
|
||||||
(fetch as any).mockRejectedValueOnce(new Error('Network error'));
|
(fetch as Mock).mockRejectedValueOnce(new Error('Network error'));
|
||||||
|
|
||||||
const result = await api.getSession();
|
const result = await api.getSession();
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
@@ -165,7 +163,7 @@ describe('ApiClient', () => {
|
|||||||
|
|
||||||
await api.getTasks({ projectId: 'proj1', completed: false, today: true });
|
await api.getTasks({ projectId: 'proj1', completed: false, today: true });
|
||||||
|
|
||||||
const calledUrl = (fetch as any).mock.calls[0][0] as string;
|
const calledUrl = (fetch as Mock).mock.calls[0][0] as string;
|
||||||
expect(calledUrl).toContain('/api/tasks?');
|
expect(calledUrl).toContain('/api/tasks?');
|
||||||
expect(calledUrl).toContain('projectId=proj1');
|
expect(calledUrl).toContain('projectId=proj1');
|
||||||
expect(calledUrl).toContain('completed=false');
|
expect(calledUrl).toContain('completed=false');
|
||||||
@@ -259,14 +257,14 @@ describe('ApiClient', () => {
|
|||||||
|
|
||||||
await api.getProjects();
|
await api.getProjects();
|
||||||
|
|
||||||
const headers = (fetch as any).mock.calls[0][1].headers;
|
const headers = (fetch as Mock).mock.calls[0][1].headers;
|
||||||
expect(headers['Authorization']).toBeUndefined();
|
expect(headers['Authorization']).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Error handling', () => {
|
describe('Error handling', () => {
|
||||||
it('throws error with message from non-200 response', async () => {
|
it('throws error with message from non-200 response', async () => {
|
||||||
(fetch as any).mockResolvedValueOnce({
|
(fetch as Mock).mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: false,
|
||||||
json: () => Promise.resolve({ error: 'Not found' }),
|
json: () => Promise.resolve({ error: 'Not found' }),
|
||||||
});
|
});
|
||||||
@@ -275,7 +273,7 @@ describe('ApiClient', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('throws "Request failed" when error body is unparseable', async () => {
|
it('throws "Request failed" when error body is unparseable', async () => {
|
||||||
(fetch as any).mockResolvedValueOnce({
|
(fetch as Mock).mockResolvedValueOnce({
|
||||||
ok: false,
|
ok: false,
|
||||||
json: () => Promise.reject(new Error('parse error')),
|
json: () => Promise.reject(new Error('parse error')),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface BoardViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TaskCard({ task, muted }: { task: Task; muted?: boolean }) {
|
function TaskCard({ task, muted }: { task: Task; muted?: boolean }) {
|
||||||
const { toggleComplete, setSelectedTask } = useTasksStore();
|
const { setSelectedTask } = useTasksStore();
|
||||||
const overdue = !task.isCompleted && isOverdue(task.dueDate);
|
const overdue = !task.isCompleted && isOverdue(task.dueDate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -84,6 +84,7 @@ function EditableHeader({
|
|||||||
title,
|
title,
|
||||||
count,
|
count,
|
||||||
sectionId,
|
sectionId,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
projectId,
|
projectId,
|
||||||
onRename,
|
onRename,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
import { useParams, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { LayoutList, LayoutGrid, Plus, Pencil, Trash2, AlertCircle } from 'lucide-react';
|
import { LayoutList, LayoutGrid, Pencil, Trash2, AlertCircle } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useTasksStore } from '@/stores/tasks';
|
import { useTasksStore } from '@/stores/tasks';
|
||||||
import { TaskItem } from '@/components/TaskItem';
|
import { TaskItem } from '@/components/TaskItem';
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ interface AuthState {
|
|||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
export const useAuthStore = create<AuthState>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set) => ({
|
||||||
user: null,
|
user: null,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user