From ced9c8555a4d925a25de1104d9194b97fe44aba8 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Thu, 6 Aug 2026 12:45:17 +0000
Subject: [PATCH 1/5] feature: port Angular user profile page to React UserPage
Co-Authored-By: Vibha Seshadri
---
web/src/pages/UserPage.tsx | 71 ++++++++++++++++++++--
web/src/user/UserPage.test.tsx | 105 +++++++++++++++++++++++++++++++++
web/src/user/user.scss | 89 ++++++++++++++++++++++++++++
3 files changed, 260 insertions(+), 5 deletions(-)
create mode 100644 web/src/user/UserPage.test.tsx
create mode 100644 web/src/user/user.scss
diff --git a/web/src/pages/UserPage.tsx b/web/src/pages/UserPage.tsx
index 89348c35..9a550607 100644
--- a/web/src/pages/UserPage.tsx
+++ b/web/src/pages/UserPage.tsx
@@ -1,9 +1,70 @@
-/**
- * Placeholder for the ported `UserComponent`, implemented in Phase 2d.
- * The user id comes from the `/user/:id` route via `useParams`.
- */
+import { useEffect, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+
+import { fetchUser } from '../api/hackerNews';
+import { ErrorMessage } from '../components/ErrorMessage';
+import { Loader } from '../components/Loader';
+import { User } from '../models/user';
+import '../user/user.scss';
+
export function UserPage() {
- return null;
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const [user, setUser] = useState(null);
+ const [errorMessage, setErrorMessage] = useState('');
+
+ useEffect(() => {
+ if (!id) {
+ return;
+ }
+
+ let cancelled = false;
+ setUser(null);
+ setErrorMessage('');
+
+ fetchUser(id)
+ .then((data) => {
+ if (!cancelled) {
+ setUser(data);
+ }
+ })
+ .catch(() => {
+ if (!cancelled) {
+ setErrorMessage(`Could not load user ${id}.`);
+ }
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, [id]);
+
+ const goBack = () => navigate(-1);
+
+ if (!user) {
+ return errorMessage !== '' ? : ;
+ }
+
+ return (
+
+
+
+
+ Profile: {user.id}
+
+
+
+
{user.id}
+
{user.karma} ★
+
Created {user.created}
+
+ {user.about && (
+
+ )}
+
+ );
}
export default UserPage;
diff --git a/web/src/user/UserPage.test.tsx b/web/src/user/UserPage.test.tsx
new file mode 100644
index 00000000..8856fe54
--- /dev/null
+++ b/web/src/user/UserPage.test.tsx
@@ -0,0 +1,105 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { fetchUser } from '../api/hackerNews';
+import { SettingsProvider } from '../context/SettingsContext';
+import { User } from '../models/user';
+import { stubMatchMedia } from '../testUtils/matchMedia';
+import { UserPage } from '../pages/UserPage';
+
+vi.mock('../api/hackerNews', () => ({
+ fetchUser: vi.fn(),
+}));
+
+const fetchUserMock = vi.mocked(fetchUser);
+
+const user: User = {
+ id: 'pg',
+ created: '4230 days ago',
+ karma: 155000,
+ about: 'Y Combinator
indented ',
+};
+
+function renderUserPage(entries: string[] = ['/user/pg'], initialIndex = 0) {
+ return render(
+
+
+
+ news feed
} />
+ } />
+
+
+
+ );
+}
+
+describe('UserPage', () => {
+ beforeEach(() => {
+ stubMatchMedia(false);
+ fetchUserMock.mockReset();
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ localStorage.clear();
+ });
+
+ it('shows the loader until the user has been fetched', async () => {
+ fetchUserMock.mockResolvedValue(user);
+
+ const { container } = renderUserPage();
+
+ expect(container.querySelector('.loading-section .loader')).not.toBeNull();
+ expect(await screen.findByText('Created 4230 days ago')).toBeInTheDocument();
+ expect(container.querySelector('.loading-section')).toBeNull();
+ });
+
+ it('renders the profile of the fetched user', async () => {
+ fetchUserMock.mockResolvedValue(user);
+
+ const { container } = renderUserPage();
+
+ await screen.findByText('Created 4230 days ago');
+
+ expect(fetchUserMock).toHaveBeenCalledWith('pg');
+ expect(screen.getByText('Profile: pg')).toHaveClass('title-block');
+ expect(container.querySelector('.mobile.item-header .back-button')).not.toBeNull();
+ expect(container.querySelector('.main-details .name')).toHaveTextContent('pg');
+ expect(container.querySelector('.main-details .right')).toHaveTextContent('155000 ★');
+ expect(container.querySelector('.other-details p')?.innerHTML).toBe('Y Combinator
indented ');
+ });
+
+ it('omits the about section for a user without an about text', async () => {
+ fetchUserMock.mockResolvedValue({ id: 'lurker', created: '2 days ago', karma: 1 });
+
+ const { container } = renderUserPage(['/user/lurker']);
+
+ await screen.findByText('Created 2 days ago');
+
+ expect(container.querySelector('.other-details')).toBeNull();
+ });
+
+ it('shows an error message when the user could not be loaded', async () => {
+ fetchUserMock.mockRejectedValue(new Error('offline'));
+
+ const { container } = renderUserPage(['/user/ghost']);
+
+ expect(await screen.findByText('Could not load user ghost.')).toBeInTheDocument();
+ expect(container.querySelector('.profile')).toBeNull();
+ expect(container.querySelector('.loading-section')).toBeNull();
+ });
+
+ it('goes back in the history when the back button is clicked', async () => {
+ fetchUserMock.mockResolvedValue(user);
+
+ const { container } = renderUserPage(['/news/1', '/user/pg'], 1);
+
+ await screen.findByText('Created 4230 days ago');
+
+ await userEvent.click(container.querySelector('.back-button') as HTMLElement);
+
+ await waitFor(() => expect(screen.getByText('news feed')).toBeInTheDocument());
+ });
+});
diff --git a/web/src/user/user.scss b/web/src/user/user.scss
new file mode 100644
index 00000000..0288468e
--- /dev/null
+++ b/web/src/user/user.scss
@@ -0,0 +1,89 @@
+@import '../styles/media';
+@import '../styles/theme_variables';
+
+.profile pre {
+ white-space: pre-wrap;
+}
+
+.profile {
+ padding: 30px;
+}
+
+@media #{$mobile-only} {
+ .profile {
+ padding: 110px 15px 0 15px;
+ }
+ .title-block {
+ font-size: 15px;
+ text-align: center;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ margin: 0 75px;
+ }
+ .back-button {
+ position: absolute;
+ top: 52%;
+ width: 0.6rem;
+ height: 0.6rem;
+ background: transparent;
+ box-shadow: 0 0 0 lightgray;
+ transition: all 200ms ease;
+ left: 4%;
+ transform: translate3d(0, -50%, 0) rotate(-135deg);
+ }
+ .item-header {
+ padding-bottom: 10px;
+ background-color: #fff;
+ padding: 10px 0 10px 0;
+ position: fixed;
+ width: 100%;
+ left: 0;
+ top: 62px;
+ height: 20px;
+ }
+}
+
+@media #{$laptop-only} {
+ .mobile {
+ display: none;
+ }
+}
+
+.main-details {
+ .name {
+ font-weight: bold;
+ font-size: 32px;
+ letter-spacing: 2px;
+ }
+ .age {
+ font-weight: bold;
+ color: #696969;
+ padding-bottom: 0;
+ }
+ .right {
+ float: right;
+ font-weight: bold;
+ font-size: 32px;
+ letter-spacing: 2px;
+ }
+}
+
+@media #{$mobile-only} {
+ .main-details {
+ margin-top: 20px;
+ .name {
+ font-size: 18px;
+ }
+ }
+}
+
+@media #{$mobile-only} {
+ .main-details .right {
+ font-size: 18px;
+ }
+}
+
+.other-details {
+ word-wrap: break-word;
+}
From 4d5c98c5b35aebec6de17cf1006cbb47c868c1d6 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Thu, 6 Aug 2026 12:45:28 +0000
Subject: [PATCH 2/5] feature: port Angular feed and item components to React
(Phase 2b)
Co-Authored-By: Vibha Seshadri
---
web/src/feeds/Item.test.tsx | 140 +++++++++++++++++++++++++
web/src/feeds/Item.tsx | 82 +++++++++++++++
web/src/feeds/feed.scss | 107 ++++++++++++++++++++
web/src/feeds/item.scss | 68 +++++++++++++
web/src/pages/FeedPage.test.tsx | 174 ++++++++++++++++++++++++++++++++
web/src/pages/FeedPage.tsx | 87 +++++++++++++++-
6 files changed, 653 insertions(+), 5 deletions(-)
create mode 100644 web/src/feeds/Item.test.tsx
create mode 100644 web/src/feeds/Item.tsx
create mode 100644 web/src/feeds/feed.scss
create mode 100644 web/src/feeds/item.scss
create mode 100644 web/src/pages/FeedPage.test.tsx
diff --git a/web/src/feeds/Item.test.tsx b/web/src/feeds/Item.test.tsx
new file mode 100644
index 00000000..bd53376e
--- /dev/null
+++ b/web/src/feeds/Item.test.tsx
@@ -0,0 +1,140 @@
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router-dom';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { Item } from './Item';
+import { SettingsProvider } from '../context/SettingsContext';
+import { Story } from '../models/story';
+import { stubMatchMedia } from '../testUtils/matchMedia';
+
+function makeStory(overrides: Partial = {}): Story {
+ return {
+ id: 1,
+ title: 'A React story',
+ points: 42,
+ user: 'dan',
+ time: 1600000000,
+ time_ago: '2 hours ago',
+ type: 'story',
+ url: 'https://example.com/story',
+ domain: 'example.com',
+ comments_count: 3,
+ ...overrides,
+ };
+}
+
+function renderItem(story: Story) {
+ stubMatchMedia(false);
+
+ return render(
+
+
+
+
+
+ );
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('Item', () => {
+ it('renders an external link with its domain for stories that have a url', () => {
+ renderItem(makeStory());
+
+ const title = screen.getByRole('link', { name: 'A React story' });
+ expect(title).toHaveClass('title');
+ expect(title).toHaveAttribute('href', 'https://example.com/story');
+ expect(title).not.toHaveAttribute('target');
+ expect(title).not.toHaveAttribute('rel');
+ expect(screen.getByText('(example.com)')).toHaveClass('domain');
+ });
+
+ it('links to the item details page for stories without an external url', () => {
+ renderItem(makeStory({ id: 7, url: 'item?id=7', domain: undefined }));
+
+ const title = screen.getByRole('link', { name: 'A React story' });
+ expect(title).toHaveClass('title');
+ expect(title).toHaveAttribute('href', '/item/7');
+ expect(screen.queryByText(/\(.*\)/)).toBeNull();
+ });
+
+ it('links to the item details page when the story has no url at all', () => {
+ renderItem(makeStory({ id: 9, url: undefined, domain: undefined }));
+
+ expect(screen.getByRole('link', { name: 'A React story' })).toHaveAttribute('href', '/item/9');
+ });
+
+ it('renders the user, points, time and comment count', () => {
+ const { container } = renderItem(makeStory({ id: 5, user: 'pg', points: 12, comments_count: 1 }));
+
+ const userLinks = screen.getAllByRole('link', { name: 'pg' });
+ expect(userLinks).toHaveLength(2);
+ userLinks.forEach((link) => expect(link).toHaveAttribute('href', '/user/pg'));
+ expect(screen.getByText('12 ★')).toBeInTheDocument();
+
+ const commentLinks = screen.getAllByRole('link', { name: '1 comment' });
+ expect(commentLinks).toHaveLength(1);
+ expect(commentLinks[0]).toHaveAttribute('href', '/item/5');
+ expect(screen.getByRole('link', { name: '• 1 comment' })).toHaveClass('comment-number');
+
+ expect(container.querySelector('.subtext-palm')).toHaveTextContent('2 hours ago • 1 comment');
+ expect(container.querySelector('.subtext-laptop')).toHaveTextContent('12 points by pg2 hours ago | 1 comment');
+ });
+
+ it('renders "discuss" when a story has no comments', () => {
+ const { container } = renderItem(makeStory({ comments_count: 0 }));
+
+ expect(container.querySelector('.subtext-palm')).toHaveTextContent('• discuss');
+ expect(container.querySelector('.subtext-laptop')).toHaveTextContent('| discuss');
+ });
+
+ it('omits the user, points and comments for job items', () => {
+ renderItem(makeStory({ type: 'job', title: 'Work at a startup', comments_count: 0 }));
+
+ expect(screen.queryByRole('link', { name: 'dan' })).toBeNull();
+ expect(screen.queryByText('42 ★')).toBeNull();
+ expect(screen.queryByRole('link', { name: /discuss/ })).toBeNull();
+ expect(screen.getAllByText('2 hours ago')).toHaveLength(2);
+ });
+
+ it('does not add the item-details class on the laptop subtext for job items', () => {
+ const { container } = renderItem(makeStory({ type: 'job' }));
+
+ expect(container.querySelector('.subtext-laptop .item-details')).toBeNull();
+ });
+
+ it('adds the item-details class on the laptop subtext for regular items', () => {
+ const { container } = renderItem(makeStory());
+
+ expect(container.querySelector('.subtext-laptop .item-details')).not.toBeNull();
+ });
+
+ it('opens external links in a new tab when the setting is enabled', () => {
+ localStorage.setItem('openLinkInNewTab', 'true');
+
+ renderItem(makeStory());
+
+ const title = screen.getByRole('link', { name: 'A React story' });
+ expect(title).toHaveAttribute('target', '_blank');
+ expect(title).toHaveAttribute('rel', 'noopener');
+ });
+
+ it('applies the title font size and list spacing settings', () => {
+ localStorage.setItem('titleFontSize', '20');
+ localStorage.setItem('listSpacing', '15');
+
+ const { container } = renderItem(makeStory());
+
+ expect(screen.getByRole('link', { name: 'A React story' })).toHaveStyle({ fontSize: '20px' });
+ expect(container.querySelector('.item-block > div')).toHaveStyle({ marginBottom: '15px' });
+ });
+
+ it('falls back to the default font size and spacing', () => {
+ const { container } = renderItem(makeStory());
+
+ expect(screen.getByRole('link', { name: 'A React story' })).toHaveStyle({ fontSize: '16px' });
+ expect(container.querySelector('.item-block > div')).toHaveStyle({ marginBottom: '0px' });
+ });
+});
diff --git a/web/src/feeds/Item.tsx b/web/src/feeds/Item.tsx
new file mode 100644
index 00000000..3a11668b
--- /dev/null
+++ b/web/src/feeds/Item.tsx
@@ -0,0 +1,82 @@
+import { NavLink } from 'react-router-dom';
+
+import { useSettings } from '../context/SettingsContext';
+import { Story } from '../models/story';
+import { formatCommentCount } from '../utils/formatCommentCount';
+import './item.scss';
+
+export interface ItemProps {
+ item: Story;
+ className?: string;
+}
+
+export function Item({ item, className }: ItemProps) {
+ const { settings } = useSettings();
+ const hasUrl = item.url !== undefined && item.url.indexOf('http') === 0;
+ const isJob = item.type === 'job';
+ const titleStyle = { fontSize: `${settings.titleFontSize}px` };
+
+ return (
+
+
+ {hasUrl ? (
+
+
+ {item.title}
+
+ {item.domain && ({item.domain}) }
+
+ ) : (
+
+
+ {item.title}
+
+
+ )}
+
+ {!isJob && (
+
+
+ {item.user}
+
+ {item.points} ★
+
+ )}
+
+ {item.time_ago}
+ {!isJob && (
+
+ {' '}
+ • {formatCommentCount(item.comments_count)}
+
+ )}
+
+
+
+ {!isJob && (
+
+ {item.points} points by {item.user}
+
+ )}
+
+ {item.time_ago}
+ {!isJob && (
+
+ {' '}
+ | {formatCommentCount(item.comments_count)}
+
+ )}
+
+
+
+
+ );
+}
+
+export default Item;
diff --git a/web/src/feeds/feed.scss b/web/src/feeds/feed.scss
new file mode 100644
index 00000000..946131da
--- /dev/null
+++ b/web/src/feeds/feed.scss
@@ -0,0 +1,107 @@
+@import '../styles/media';
+@import '../styles/theme_variables';
+
+a {
+ text-decoration: none;
+ font-weight: bold;
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+ol {
+ padding: 0 40px;
+ margin: 0;
+
+ @media #{$mobile-only} {
+ box-sizing: border-box;
+ list-style: none;
+ padding: 0 10px;
+ }
+
+ li {
+ position: relative;
+ -webkit-transition: background-color 0.2s ease;
+ transition: background-color 0.2s ease;
+ }
+}
+
+.list-margin {
+ @media #{$mobile-only} {
+ margin-top: 55px;
+ }
+}
+
+.main-content {
+ position: relative;
+ width: 100%;
+ min-height: 100vh;
+ -webkit-transition: opacity 0.2s ease;
+ transition: opacity 0.2s ease;
+ box-sizing: border-box;
+ padding: 8px 0;
+ z-index: 0;
+}
+
+.post {
+ padding: 10px 0 10px 5px;
+ transition: background-color 0.2s ease;
+ border-bottom: 1px solid #cececb;
+
+ .itemNum {
+ color: #696969;
+ position: absolute;
+ width: 30px;
+ text-align: right;
+ left: 0;
+ top: 4px;
+ }
+}
+
+.item-block {
+ display: block;
+}
+
+.nav {
+ padding: 10px 40px;
+ margin-top: 10px;
+ font-size: 17px;
+
+ a {
+ @media #{$mobile-only} {
+ text-decoration: none;
+ }
+ }
+
+ @media #{$mobile-only} {
+ margin: 20px 0;
+ text-align: center;
+ padding: 10px 80px;
+ height: 20px;
+ }
+
+ .prev {
+ padding-right: 20px;
+
+ @media #{$mobile-only} {
+ float: left;
+ padding-right: 0;
+ }
+ }
+
+ .more {
+ @media #{$mobile-only} {
+ float: right;
+ }
+ }
+}
+
+.job-header {
+ font-size: 15px;
+ padding: 0 40px 10px;
+
+ @media #{$mobile-only} {
+ padding: 60px 15px 25px 15px;
+ }
+}
diff --git a/web/src/feeds/item.scss b/web/src/feeds/item.scss
new file mode 100644
index 00000000..f1acb15b
--- /dev/null
+++ b/web/src/feeds/item.scss
@@ -0,0 +1,68 @@
+@import '../styles/media';
+@import '../styles/theme_variables';
+
+p {
+ margin: 2px 0;
+
+ @media #{$mobile-only} {
+ margin-bottom: 5px;
+ margin-top: 0;
+ }
+}
+
+a {
+ cursor: pointer;
+ text-decoration: none;
+}
+
+.title {
+ font-size: 16px;
+ font-family: Verdana, Geneva, sans-serif;
+}
+
+.subtext-laptop {
+ font-size: 12px;
+ font-weight: bold;
+ letter-spacing: 0.5px;
+
+ a {
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ @media #{$mobile-only} {
+ display: none;
+ }
+}
+
+.subtext-palm {
+ font-size: 13px;
+ font-weight: bold;
+ letter-spacing: 0.5px;
+
+ a {
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .details {
+ margin-top: 5px;
+
+ .right {
+ float: right;
+ }
+ }
+ @media #{$laptop-only} {
+ display: none;
+ }
+}
+
+.domain {
+ color: #696969;
+ letter-spacing: 0.5px;
+}
+
+.item-details {
+ padding: 10px;
+}
diff --git a/web/src/pages/FeedPage.test.tsx b/web/src/pages/FeedPage.test.tsx
new file mode 100644
index 00000000..fe3077b1
--- /dev/null
+++ b/web/src/pages/FeedPage.test.tsx
@@ -0,0 +1,174 @@
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { FeedPage } from './FeedPage';
+import { fetchFeed } from '../api/hackerNews';
+import { SettingsProvider } from '../context/SettingsContext';
+import { Story } from '../models/story';
+import { stubMatchMedia } from '../testUtils/matchMedia';
+
+vi.mock('../api/hackerNews');
+
+const fetchFeedMock = vi.mocked(fetchFeed);
+
+function makeStories(count: number, startId = 1): Story[] {
+ return Array.from({ length: count }, (_, index) => ({
+ id: startId + index,
+ title: `Story ${startId + index}`,
+ points: 10 + index,
+ user: `user${startId + index}`,
+ time: 1600000000,
+ time_ago: '1 hour ago',
+ type: 'story' as const,
+ url: `https://example.com/${startId + index}`,
+ domain: 'example.com',
+ comments_count: index,
+ }));
+}
+
+function renderFeed(feedType = 'news', path = `/${feedType}/1`) {
+ return render(
+
+
+
+ } />
+ } />
+
+
+
+ );
+}
+
+beforeEach(() => {
+ stubMatchMedia(false);
+ vi.stubGlobal('scrollTo', vi.fn());
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+});
+
+describe('FeedPage', () => {
+ it('shows the loader while the feed is loading', () => {
+ fetchFeedMock.mockReturnValue(new Promise(() => {}));
+
+ renderFeed();
+
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
+ expect(fetchFeedMock).toHaveBeenCalledWith('news', 1);
+ });
+
+ it('renders the loaded stories and scrolls back to the top', async () => {
+ fetchFeedMock.mockResolvedValue(makeStories(3));
+
+ const { container } = renderFeed();
+
+ expect(await screen.findByText('Story 1')).toBeInTheDocument();
+ expect(screen.queryByText('Loading...')).toBeNull();
+ expect(container.querySelectorAll('li.post')).toHaveLength(3);
+ expect(container.querySelector('ol')).toHaveAttribute('start', '1');
+ expect(container.querySelector('ol')).toHaveClass('list-margin');
+ expect(window.scrollTo).toHaveBeenCalledWith(0, 0);
+ });
+
+ it('continues the rank numbering on later pages', async () => {
+ fetchFeedMock.mockResolvedValue(makeStories(30, 31));
+
+ const { container } = renderFeed('news', '/news/2');
+
+ await screen.findByText('Story 31');
+ expect(fetchFeedMock).toHaveBeenCalledWith('news', 2);
+ expect(container.querySelector('ol')).toHaveAttribute('start', '31');
+ });
+
+ it('defaults to page one when the route has no page parameter', async () => {
+ fetchFeedMock.mockResolvedValue(makeStories(1));
+
+ const { container } = renderFeed('news', '/news');
+
+ await screen.findByText('Story 1');
+ expect(fetchFeedMock).toHaveBeenCalledWith('news', 1);
+ expect(container.querySelector('ol')).toHaveAttribute('start', '1');
+ });
+
+ it('shows an error message when the feed cannot be loaded', async () => {
+ fetchFeedMock.mockRejectedValue(new Error('boom'));
+
+ renderFeed('ask', '/ask/1');
+
+ expect(await screen.findByText('Could not load ask stories.')).toBeInTheDocument();
+ expect(screen.queryByText('Loading...')).toBeNull();
+ expect(window.scrollTo).not.toHaveBeenCalled();
+ });
+
+ it('hides the previous link on the first page and shows More for a full page', async () => {
+ fetchFeedMock.mockResolvedValue(makeStories(30));
+
+ renderFeed();
+
+ const more = await screen.findByRole('link', { name: 'More ›' });
+ expect(more).toHaveAttribute('href', '/news/2');
+ expect(more).toHaveClass('more');
+ expect(screen.queryByRole('link', { name: '‹ Prev' })).toBeNull();
+ });
+
+ it('shows both pagination links on a full later page', async () => {
+ fetchFeedMock.mockResolvedValue(makeStories(30, 61));
+
+ renderFeed('news', '/news/3');
+
+ const prev = await screen.findByRole('link', { name: '‹ Prev' });
+ expect(prev).toHaveAttribute('href', '/news/2');
+ expect(prev).toHaveClass('prev');
+ expect(screen.getByRole('link', { name: 'More ›' })).toHaveAttribute('href', '/news/4');
+ });
+
+ it('hides the More link when the page is not full', async () => {
+ fetchFeedMock.mockResolvedValue(makeStories(29, 31));
+
+ renderFeed('news', '/news/2');
+
+ await screen.findByRole('link', { name: '‹ Prev' });
+ expect(screen.queryByRole('link', { name: 'More ›' })).toBeNull();
+ });
+
+ it('renders the jobs blurb and omits the list margin for the jobs feed', async () => {
+ fetchFeedMock.mockResolvedValue(makeStories(2));
+
+ const { container } = renderFeed('jobs', '/jobs/1');
+
+ await screen.findByText(/These are jobs at startups/);
+ expect(screen.getByRole('link', { name: 'Triplebyte' })).toHaveAttribute(
+ 'href',
+ 'https://triplebyte.com/?ref=yc_jobs'
+ );
+ expect(container.querySelector('.job-header')).toBeInTheDocument();
+ expect(container.querySelector('ol')).not.toHaveClass('list-margin');
+ });
+
+ it('does not render the jobs blurb for other feeds', async () => {
+ fetchFeedMock.mockResolvedValue(makeStories(2));
+
+ const { container } = renderFeed('show', '/show/1');
+
+ await screen.findByText('Story 1');
+ expect(container.querySelector('.job-header')).toBeNull();
+ });
+
+ it('refetches the feed when navigating to the next page', async () => {
+ fetchFeedMock.mockResolvedValueOnce(makeStories(30)).mockResolvedValueOnce(makeStories(30, 31));
+
+ renderFeed();
+
+ await screen.findByText('Story 1');
+ await userEvent.click(screen.getByRole('link', { name: 'More ›' }));
+
+ expect(await screen.findByText('Story 31')).toBeInTheDocument();
+ await waitFor(() => expect(fetchFeedMock).toHaveBeenCalledTimes(2));
+ expect(fetchFeedMock).toHaveBeenLastCalledWith('news', 2);
+ expect(screen.queryByText('Story 1')).toBeNull();
+ });
+});
diff --git a/web/src/pages/FeedPage.tsx b/web/src/pages/FeedPage.tsx
index 62025f0a..3110af75 100644
--- a/web/src/pages/FeedPage.tsx
+++ b/web/src/pages/FeedPage.tsx
@@ -1,12 +1,89 @@
+import { useEffect, useState } from 'react';
+import { NavLink, useParams } from 'react-router-dom';
+
+import { fetchFeed } from '../api/hackerNews';
+import { ErrorMessage } from '../components/ErrorMessage';
+import { Loader } from '../components/Loader';
+import { Item } from '../feeds/Item';
+import { Story } from '../models/story';
+import '../feeds/feed.scss';
+
export interface FeedPageProps {
feedType: string;
}
-/**
- * Placeholder for the ported `FeedComponent`, implemented in Phase 2b.
- */
-export function FeedPage(_props: FeedPageProps) {
- return null;
+export function FeedPage({ feedType }: FeedPageProps) {
+ const { page } = useParams();
+ const pageNum = page ? Number(page) : 1;
+ const [items, setItems] = useState(null);
+ const [errorMessage, setErrorMessage] = useState('');
+
+ useEffect(() => {
+ let cancelled = false;
+
+ setItems(null);
+ setErrorMessage('');
+
+ fetchFeed(feedType, pageNum).then(
+ (feedItems) => {
+ if (cancelled) {
+ return;
+ }
+ setItems(feedItems);
+ window.scrollTo(0, 0);
+ },
+ () => {
+ if (!cancelled) {
+ setErrorMessage(`Could not load ${feedType} stories.`);
+ }
+ }
+ );
+
+ return () => {
+ cancelled = true;
+ };
+ }, [feedType, pageNum]);
+
+ const listStart = (pageNum - 1) * 30 + 1;
+
+ return (
+
+ {!items && errorMessage === '' &&
}
+ {!items && errorMessage !== '' &&
}
+
+ {items && (
+
+ {feedType === 'jobs' && (
+
+ These are jobs at startups that were funded by Y Combinator. You can also get a job at a YC
+ startup through Triplebyte .
+
+ )}
+ {feedType !== 'new' && (
+
+ {items.map((item) => (
+
+
+
+ ))}
+
+ )}
+
+ {listStart !== 1 && (
+
+ ‹ Prev
+
+ )}
+ {items.length === 30 && (
+
+ More ›
+
+ )}
+
+
+ )}
+
+ );
}
export default FeedPage;
From 9e3e2da4d2acc065a5e4d1be16ca51b78d506140 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Thu, 6 Aug 2026 12:46:16 +0000
Subject: [PATCH 3/5] feature: port the app shell, router, header, footer and
settings modal to React
Co-Authored-By: Vibha Seshadri
---
web/src/App.test.tsx | 69 +++++++++++----
web/src/App.tsx | 29 +++++--
web/src/core/Footer.test.tsx | 17 ++++
web/src/core/Footer.tsx | 16 ++++
web/src/core/Header.test.tsx | 80 ++++++++++++++++++
web/src/core/Header.tsx | 51 +++++++++++
web/src/core/Settings.test.tsx | 141 +++++++++++++++++++++++++++++++
web/src/core/Settings.tsx | 100 ++++++++++++++++++++++
web/src/core/footer.scss | 23 +++++
web/src/core/header.scss | 149 +++++++++++++++++++++++++++++++++
web/src/core/settings.scss | 75 +++++++++++++++++
web/src/main.tsx | 7 +-
web/src/routes.test.tsx | 94 +++++++++++++++++++++
web/src/routes.tsx | 31 +++++++
14 files changed, 861 insertions(+), 21 deletions(-)
create mode 100644 web/src/core/Footer.test.tsx
create mode 100644 web/src/core/Footer.tsx
create mode 100644 web/src/core/Header.test.tsx
create mode 100644 web/src/core/Header.tsx
create mode 100644 web/src/core/Settings.test.tsx
create mode 100644 web/src/core/Settings.tsx
create mode 100644 web/src/core/footer.scss
create mode 100644 web/src/core/header.scss
create mode 100644 web/src/core/settings.scss
create mode 100644 web/src/routes.test.tsx
create mode 100644 web/src/routes.tsx
diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
index de1a37e5..9323ceac 100644
--- a/web/src/App.test.tsx
+++ b/web/src/App.test.tsx
@@ -1,40 +1,81 @@
import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { App } from './App';
import { SettingsProvider } from './context/SettingsContext';
import { stubMatchMedia } from './testUtils/matchMedia';
+function renderApp(initialEntry = '/news/1') {
+ vi.stubGlobal('scrollTo', vi.fn());
+
+ return render(
+
+
+
+ }>
+ news feed} />
+ show feed} />
+
+
+
+
+ );
+}
+
afterEach(() => {
vi.unstubAllGlobals();
});
describe('App', () => {
- it('wraps its children in the themed shell', () => {
+ it('renders the themed shell with the header, the routed page and the footer', () => {
stubMatchMedia(false);
- const { container } = render(
-
-
- content
-
-
- );
+ const { container } = renderApp();
expect(container.querySelector('.default .body-cover')).not.toBeNull();
- expect(screen.getByText('content').parentElement).toHaveClass('wrapper');
+ expect(screen.getByText('news feed').parentElement).toHaveClass('wrapper');
+ expect(container.querySelector('.wrapper #header')).not.toBeNull();
+ expect(container.querySelector('.wrapper #footer')).not.toBeNull();
});
it('applies the theme coming from the settings', () => {
localStorage.setItem('theme', 'amoledblack');
stubMatchMedia(false);
- const { container } = render(
-
-
-
- );
+ const { container } = renderApp();
expect(container.querySelector('.amoledblack')).not.toBeNull();
});
+
+ it('sends a Google Analytics pageview on the initial render and on every navigation', async () => {
+ stubMatchMedia(false);
+ const ga = vi.fn();
+ vi.stubGlobal('ga', ga);
+
+ renderApp();
+
+ expect(ga.mock.calls).toEqual([
+ ['set', 'page', '/news/1'],
+ ['send', 'pageview'],
+ ]);
+
+ ga.mockClear();
+ await userEvent.click(screen.getByRole('link', { name: 'show' }));
+
+ expect(screen.getByText('show feed')).toBeInTheDocument();
+ expect(ga.mock.calls).toEqual([
+ ['set', 'page', '/show/1'],
+ ['send', 'pageview'],
+ ]);
+ });
+
+ it('does not throw when Google Analytics is not loaded', () => {
+ stubMatchMedia(false);
+ vi.stubGlobal('ga', undefined);
+
+ expect(() => renderApp()).not.toThrow();
+ expect(screen.getByText('news feed')).toBeInTheDocument();
+ });
});
diff --git a/web/src/App.tsx b/web/src/App.tsx
index ed63d362..c47d2f5c 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -1,19 +1,38 @@
-import { ReactNode } from 'react';
+import { useEffect } from 'react';
+import { Outlet, useLocation } from 'react-router-dom';
import { useSettings } from './context/SettingsContext';
+import { Footer } from './core/Footer';
+import { Header } from './core/Header';
import './App.scss';
-export interface AppProps {
- children?: ReactNode;
+declare global {
+ interface Window {
+ ga?: (...args: unknown[]) => void;
+ }
}
-export function App({ children }: AppProps) {
+export function App() {
const { settings } = useSettings();
+ const { pathname } = useLocation();
+
+ useEffect(() => {
+ if (typeof window.ga !== 'function') {
+ return;
+ }
+
+ window.ga('set', 'page', pathname);
+ window.ga('send', 'pageview');
+ }, [pathname]);
return (
);
}
diff --git a/web/src/core/Footer.test.tsx b/web/src/core/Footer.test.tsx
new file mode 100644
index 00000000..91e7c892
--- /dev/null
+++ b/web/src/core/Footer.test.tsx
@@ -0,0 +1,17 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { Footer } from './Footer';
+
+describe('Footer', () => {
+ it('links to the project on GitHub', () => {
+ const { container } = render();
+
+ expect(container.querySelector('#footer p')?.textContent).toBe('Show this project some ❤ on GitHub');
+
+ const link = screen.getByRole('link', { name: 'GitHub' });
+ expect(link).toHaveAttribute('href', 'https://github.com/hdjirdeh/angular2-hn');
+ expect(link).toHaveAttribute('target', '_blank');
+ expect(link).toHaveAttribute('rel', 'noopener');
+ });
+});
diff --git a/web/src/core/Footer.tsx b/web/src/core/Footer.tsx
new file mode 100644
index 00000000..c91ef894
--- /dev/null
+++ b/web/src/core/Footer.tsx
@@ -0,0 +1,16 @@
+import './footer.scss';
+
+export function Footer() {
+ return (
+
+ );
+}
+
+export default Footer;
diff --git a/web/src/core/Header.test.tsx b/web/src/core/Header.test.tsx
new file mode 100644
index 00000000..7b26e883
--- /dev/null
+++ b/web/src/core/Header.test.tsx
@@ -0,0 +1,80 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { SettingsProvider } from '../context/SettingsContext';
+import { stubMatchMedia } from '../testUtils/matchMedia';
+import { Header } from './Header';
+
+const scrollTo = vi.fn();
+
+beforeEach(() => {
+ scrollTo.mockClear();
+ vi.stubGlobal('scrollTo', scrollTo);
+});
+
+function renderHeader(initialEntry = '/news/1') {
+ stubMatchMedia(false);
+
+ return render(
+
+
+
+
+
+ );
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('Header', () => {
+ it('links the logo to the first news page and the nav to the other feeds', () => {
+ const { container } = renderHeader();
+
+ expect(screen.getByRole('img', { name: 'Logo' }).closest('a')).toHaveAttribute('href', '/news/1');
+ expect(screen.getByRole('link', { name: 'new' })).toHaveAttribute('href', '/newest/1');
+ expect(screen.getByRole('link', { name: 'show' })).toHaveAttribute('href', '/show/1');
+ expect(screen.getByRole('link', { name: 'ask' })).toHaveAttribute('href', '/ask/1');
+ expect(screen.getByRole('link', { name: 'jobs' })).toHaveAttribute('href', '/jobs/1');
+ expect(container.querySelector('.header-nav')?.textContent).toBe('new | show | ask | jobs');
+ });
+
+ it('marks the link of the current feed as active', () => {
+ renderHeader('/ask/1');
+
+ expect(screen.getByRole('link', { name: 'ask' })).toHaveClass('active');
+ expect(screen.getByRole('link', { name: 'show' })).not.toHaveClass('active');
+ expect(screen.getByRole('img', { name: 'Logo' }).closest('a')).not.toHaveClass('active');
+ });
+
+ it('scrolls back to the top when a link is clicked', async () => {
+ renderHeader();
+ await userEvent.click(screen.getByRole('link', { name: 'new' }));
+
+ expect(scrollTo).toHaveBeenCalledWith(0, 0);
+ });
+
+ it('opens and closes the settings modal from the cog', async () => {
+ renderHeader();
+
+ expect(document.getElementById('popup1')).toBeNull();
+
+ await userEvent.click(screen.getByRole('img', { name: 'Settings' }));
+ expect(document.getElementById('popup1')).not.toBeNull();
+
+ await userEvent.click(screen.getByRole('img', { name: 'Settings' }));
+ expect(document.getElementById('popup1')).toBeNull();
+ });
+
+ it('closes the settings modal from its close button', async () => {
+ const { container } = renderHeader();
+
+ await userEvent.click(screen.getByRole('img', { name: 'Settings' }));
+ await userEvent.click(container.querySelector('.close')!);
+
+ expect(document.getElementById('popup1')).toBeNull();
+ });
+});
diff --git a/web/src/core/Header.tsx b/web/src/core/Header.tsx
new file mode 100644
index 00000000..6395905c
--- /dev/null
+++ b/web/src/core/Header.tsx
@@ -0,0 +1,51 @@
+import { NavLink } from 'react-router-dom';
+
+import { useSettings } from '../context/SettingsContext';
+import { Settings } from './Settings';
+import './header.scss';
+
+function scrollTop() {
+ window.scrollTo(0, 0);
+}
+
+export function Header() {
+ const { settings, toggleSettings } = useSettings();
+
+ return (
+
+
+ {settings.showSettings && }
+
+ );
+}
+
+export default Header;
diff --git a/web/src/core/Settings.test.tsx b/web/src/core/Settings.test.tsx
new file mode 100644
index 00000000..786c9223
--- /dev/null
+++ b/web/src/core/Settings.test.tsx
@@ -0,0 +1,141 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { SettingsProvider, useSettings } from '../context/SettingsContext';
+import { stubMatchMedia } from '../testUtils/matchMedia';
+import { Settings } from './Settings';
+
+function SettingsHarness() {
+ const { settings, toggleSettings } = useSettings();
+
+ return (
+ <>
+ open settings
+ {`theme: ${settings.theme}`}
+ {settings.showSettings && }
+ >
+ );
+}
+
+function renderSettings() {
+ stubMatchMedia(false);
+
+ return render(
+
+
+
+ );
+}
+
+async function openSettings() {
+ await userEvent.click(screen.getByRole('button', { name: 'open settings' }));
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('Settings', () => {
+ it('renders the modal markup', async () => {
+ const { container } = renderSettings();
+ await openSettings();
+
+ const overlay = container.querySelector('#popup1');
+ expect(overlay).toHaveClass('overlay');
+ expect(overlay?.querySelector('.popup h1')?.textContent).toBe('Settings');
+ expect(overlay?.querySelector('.close')?.textContent).toBe('×');
+ expect(overlay?.querySelectorAll('.control-section')).toHaveLength(3);
+ expect(screen.getByText('Select a theme')).toBeInTheDocument();
+ expect(screen.getByText('Change Font')).toBeInTheDocument();
+ expect(screen.getByText('Links')).toBeInTheDocument();
+ });
+
+ it('closes the modal from the × button', async () => {
+ const { container } = renderSettings();
+ await openSettings();
+
+ await userEvent.click(container.querySelector('.close')!);
+
+ expect(container.querySelector('#popup1')).toBeNull();
+ });
+
+ it('checks the radio of the current theme and updates the theme when another one is picked', async () => {
+ renderSettings();
+ await openSettings();
+
+ expect(screen.getByRole('radio', { name: 'Default' })).toBeChecked();
+
+ await userEvent.click(screen.getByRole('radio', { name: 'Night' }));
+
+ expect(screen.getByText('theme: night')).toBeInTheDocument();
+ expect(screen.getByRole('radio', { name: 'Night' })).toBeChecked();
+ expect(localStorage.getItem('theme')).toBe('night');
+
+ await userEvent.click(screen.getByRole('radio', { name: 'Black (AMOLED)' }));
+
+ expect(screen.getByText('theme: amoledblack')).toBeInTheDocument();
+ expect(localStorage.getItem('theme')).toBe('amoledblack');
+ });
+
+ it('updates the title font size on every keystroke', async () => {
+ renderSettings();
+ await openSettings();
+
+ const fontSize = screen.getByLabelText('Font size:');
+ expect(fontSize).toHaveValue(16);
+
+ await userEvent.clear(fontSize);
+ await userEvent.type(fontSize, '20');
+
+ expect(fontSize).toHaveValue(20);
+ expect(localStorage.getItem('titleFontSize')).toBe('20');
+ });
+
+ it('updates the list spacing on every keystroke', async () => {
+ renderSettings();
+ await openSettings();
+
+ const listSpacing = screen.getByLabelText('List spacing:');
+ expect(listSpacing).toHaveValue(0);
+
+ await userEvent.clear(listSpacing);
+ await userEvent.type(listSpacing, '5');
+
+ expect(listSpacing).toHaveValue(5);
+ expect(localStorage.getItem('listSpacing')).toBe('5');
+ });
+
+ it('toggles opening links in a new tab', async () => {
+ renderSettings();
+ await openSettings();
+
+ const checkbox = screen.getByRole('checkbox');
+ expect(checkbox).not.toBeChecked();
+
+ await userEvent.click(checkbox);
+
+ expect(checkbox).toBeChecked();
+ expect(localStorage.getItem('openLinkInNewTab')).toBe('true');
+
+ await userEvent.click(checkbox);
+
+ expect(checkbox).not.toBeChecked();
+ expect(localStorage.getItem('openLinkInNewTab')).toBe('false');
+ });
+
+ it('reflects the settings restored from localStorage', async () => {
+ localStorage.setItem('theme', 'amoledblack');
+ localStorage.setItem('titleFontSize', '22');
+ localStorage.setItem('listSpacing', '3');
+ localStorage.setItem('openLinkInNewTab', 'true');
+
+ renderSettings();
+ await openSettings();
+
+ expect(screen.getByRole('radio', { name: 'Black (AMOLED)' })).toBeChecked();
+ expect(screen.getByLabelText('Font size:')).toHaveValue(22);
+ expect(screen.getByLabelText('List spacing:')).toHaveValue(3);
+ expect(screen.getByRole('checkbox')).toBeChecked();
+ });
+});
diff --git a/web/src/core/Settings.tsx b/web/src/core/Settings.tsx
new file mode 100644
index 00000000..0f0e1fb8
--- /dev/null
+++ b/web/src/core/Settings.tsx
@@ -0,0 +1,100 @@
+import { ChangeEvent } from 'react';
+
+import { useSettings } from '../context/SettingsContext';
+import './settings.scss';
+
+export function Settings() {
+ const { settings, toggleSettings, toggleOpenLinksInNewTab, setTheme, setFont, setSpacing } = useSettings();
+
+ const changeTitleFont = (event: ChangeEvent) => setFont(event.target.value);
+ const changeSpacing = (event: ChangeEvent) => setSpacing(event.target.value);
+
+ return (
+
+ );
+}
+
+export default Settings;
diff --git a/web/src/core/footer.scss b/web/src/core/footer.scss
new file mode 100644
index 00000000..cbed2908
--- /dev/null
+++ b/web/src/core/footer.scss
@@ -0,0 +1,23 @@
+@import '../styles/media';
+@import '../styles/theme_variables';
+
+#footer {
+ position: relative;
+ padding: 10px;
+ height: 60px;
+ letter-spacing: 0.7px;
+ text-align: center;
+
+ a {
+ font-weight: bold;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ @media #{$mobile-only} {
+ display: none;
+ }
+}
diff --git a/web/src/core/header.scss b/web/src/core/header.scss
new file mode 100644
index 00000000..fdf935d7
--- /dev/null
+++ b/web/src/core/header.scss
@@ -0,0 +1,149 @@
+@import '../styles/media';
+@import '../styles/theme_variables';
+
+#header {
+ color: #fff;
+ padding: 6px 0;
+ line-height: 18px;
+ vertical-align: middle;
+ position: relative;
+ z-index: 1;
+ width: 100%;
+
+ @media #{$mobile-only} {
+ height: 50px;
+ position: fixed;
+ top: 0;
+ }
+
+ a {
+ display: inline;
+ }
+}
+
+.home-link {
+ width: 50px;
+ height: 66px;
+}
+
+.logo-inner {
+ width: 32px;
+ position: absolute;
+ left: 17px;
+ top: 18px;
+ z-index: -1;
+ height: 32px;
+ border-radius: 50%;
+
+ @media #{$mobile-only} {
+ left: 16px;
+ top: 12px;
+ }
+}
+
+.logo {
+ width: 50px;
+ padding: 3px 8px 0;
+
+ @media #{$mobile-only} {
+ width: 45px;
+ padding: 0 0 0 10px;
+ }
+}
+
+h1 {
+ font-weight: normal;
+ display: inline-block;
+ vertical-align: middle;
+ margin: 0;
+ font-size: 16px;
+
+ a {
+ color: #fff;
+ text-decoration: none;
+ }
+}
+
+.name {
+ margin-right: 30px;
+ margin-bottom: 2px;
+
+ @media #{$mobile-only} {
+ display: none;
+ }
+}
+
+.header-text {
+ position: absolute;
+ width: inherit;
+ height: 20px;
+ left: 10px;
+ top: 27px;
+ z-index: -1;
+
+ @media #{$mobile-only} {
+ top: 22px;
+ }
+}
+
+.left {
+ position: absolute;
+ left: 60px;
+ font-size: 16px;
+
+ @media #{$mobile-only} {
+ width: 100%;
+ left: 0;
+ }
+}
+
+.header-nav {
+ display: inline-block;
+ margin-left: 20px;
+
+ @media #{$mobile-only} {
+ margin-left: 60px;
+ }
+
+ a {
+ color: hsla(0, 0%, 100%, 0.9);
+ text-decoration: none;
+ margin: 0 5px;
+ letter-spacing: 1.8px;
+
+ &:hover {
+ color: #fff;
+ }
+ }
+
+ .active {
+ color: #fff;
+ }
+}
+
+.info {
+ position: absolute;
+ top: 0;
+ right: 20px;
+ height: 100%;
+
+ @media #{$mobile-only} {
+ right: 10px;
+ }
+
+ img {
+ opacity: 0.8;
+ width: 25px;
+ margin-top: 21.5px;
+ display: block;
+
+ &:hover {
+ opacity: 1;
+ cursor: pointer;
+ }
+
+ @media #{$mobile-only} {
+ margin-top: 15px;
+ }
+ }
+}
diff --git a/web/src/core/settings.scss b/web/src/core/settings.scss
new file mode 100644
index 00000000..b64ba767
--- /dev/null
+++ b/web/src/core/settings.scss
@@ -0,0 +1,75 @@
+@import '../styles/media';
+@import '../styles/theme_variables';
+
+.overlay {
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: rgba(0, 0, 0, 0.7);
+ opacity: 1;
+ z-index: 1;
+}
+
+.popup {
+ margin: 70px auto;
+ padding: 30px;
+ border-radius: 5px;
+ width: 30%;
+ position: relative;
+ h1 {
+ margin-top: 0;
+ margin-bottom: 0px;
+ color: #fff;
+ text-align: center;
+ letter-spacing: 1px;
+ }
+ h2 {
+ padding-top: 10px;
+ }
+ hr {
+ width: 40%;
+ margin-bottom: 20px;
+ }
+ .close {
+ position: absolute;
+ top: 12px;
+ right: 20px;
+ font-size: 30px;
+ font-weight: bold;
+ text-decoration: none;
+ color: rgba(255, 255, 255, 0.8);
+ &:hover {
+ color: #fff;
+ cursor: pointer;
+ }
+ }
+ .content {
+ max-height: 30%;
+ color: #fff;
+ letter-spacing: 1px;
+ overflow: auto;
+ }
+ input[type='number'] {
+ display: block;
+ width: 80%;
+ height: 20px;
+ margin-bottom: 15px;
+ border-radius: 5px;
+ padding: 2px;
+ }
+}
+
+.control-section {
+ margin-bottom: 15px;
+ padding-bottom: 15px;
+ border-bottom: 1px solid white;
+}
+
+@media screen and (max-width: 700px) {
+ .box,
+ .popup {
+ width: 70%;
+ }
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 6bd618ff..7542c32c 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -1,14 +1,17 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
+import { BrowserRouter } from 'react-router-dom';
-import { App } from './App';
import { SettingsProvider } from './context/SettingsContext';
+import { AppRoutes } from './routes';
import './styles/global.scss';
createRoot(document.getElementById('root') as HTMLElement).render(
-
+
+
+
);
diff --git a/web/src/routes.test.tsx b/web/src/routes.test.tsx
new file mode 100644
index 00000000..b179868a
--- /dev/null
+++ b/web/src/routes.test.tsx
@@ -0,0 +1,94 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, useParams } from 'react-router-dom';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { SettingsProvider } from './context/SettingsContext';
+import { FeedPageProps } from './pages/FeedPage';
+import { AppRoutes, feedTypes } from './routes';
+import { stubMatchMedia } from './testUtils/matchMedia';
+
+vi.mock('./pages/FeedPage', () => {
+ const FeedPage = ({ feedType }: FeedPageProps) => {
+ const { page } = useParams();
+ return {`feed ${feedType} page ${page}`}
;
+ };
+ return { FeedPage, default: FeedPage };
+});
+
+vi.mock('./pages/ItemDetailsPage', () => {
+ const ItemDetailsPage = () => {
+ const { id } = useParams();
+ return {`item ${id}`}
;
+ };
+ return { ItemDetailsPage, default: ItemDetailsPage };
+});
+
+vi.mock('./pages/UserPage', () => {
+ const UserPage = () => {
+ const { id } = useParams();
+ return {`user ${id}`}
;
+ };
+ return { UserPage, default: UserPage };
+});
+
+function renderRoutes(initialEntry: string) {
+ stubMatchMedia(false);
+ vi.stubGlobal('scrollTo', vi.fn());
+
+ return render(
+
+
+
+
+
+ );
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('routes', () => {
+ it('redirects the root path to the first page of the news feed', () => {
+ renderRoutes('/');
+
+ expect(screen.getByText('feed news page 1')).toBeInTheDocument();
+ });
+
+ it.each(feedTypes)('renders the %s feed with its page parameter', (feedType) => {
+ renderRoutes(`/${feedType}/3`);
+
+ expect(screen.getByText(`feed ${feedType} page 3`)).toBeInTheDocument();
+ });
+
+ it('renders the item details page for /item/:id', () => {
+ renderRoutes('/item/12345');
+
+ expect(screen.getByText('item 12345')).toBeInTheDocument();
+ });
+
+ it('renders the user page for /user/:id', () => {
+ renderRoutes('/user/pg');
+
+ expect(screen.getByText('user pg')).toBeInTheDocument();
+ });
+
+ it.each([
+ ['home', 'feed news page 1'],
+ ['new', 'feed newest page 1'],
+ ['show', 'feed show page 1'],
+ ['ask', 'feed ask page 1'],
+ ['jobs', 'feed jobs page 1'],
+ ])('navigates to the right feed when the %s header link is clicked', async (linkName, expectedText) => {
+ renderRoutes('/item/1');
+
+ const link =
+ linkName === 'home'
+ ? screen.getByRole('img', { name: 'Logo' }).closest('a')!
+ : screen.getByRole('link', { name: linkName });
+ await userEvent.click(link);
+
+ expect(screen.getByText(expectedText)).toBeInTheDocument();
+ });
+});
diff --git a/web/src/routes.tsx b/web/src/routes.tsx
new file mode 100644
index 00000000..6d773008
--- /dev/null
+++ b/web/src/routes.tsx
@@ -0,0 +1,31 @@
+/* eslint-disable react-refresh/only-export-components */
+import { Navigate, RouteObject, useRoutes } from 'react-router-dom';
+
+import { App } from './App';
+import { FeedPage } from './pages/FeedPage';
+import { ItemDetailsPage } from './pages/ItemDetailsPage';
+import { UserPage } from './pages/UserPage';
+
+export const feedTypes = ['news', 'newest', 'show', 'ask', 'jobs'] as const;
+
+export const routes: RouteObject[] = [
+ {
+ path: '/',
+ element: ,
+ children: [
+ { index: true, element: },
+ ...feedTypes.map((feedType) => ({
+ path: `${feedType}/:page`,
+ element: ,
+ })),
+ { path: 'item/:id', element: },
+ { path: 'user/:id', element: },
+ ],
+ },
+];
+
+export function AppRoutes() {
+ return useRoutes(routes);
+}
+
+export default routes;
From bbc2c808ea44f4fc36ebd6ad708a7b1a120cff06 Mon Sep 17 00:00:00 2001
From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Thu, 6 Aug 2026 12:46:57 +0000
Subject: [PATCH 4/5] feature: port item details page, recursive comments and
polls to React (Phase 2c)
Co-Authored-By: Vibha Seshadri
---
web/src/item-details/Comment.test.tsx | 140 +++++++++++++
web/src/item-details/Comment.tsx | 49 +++++
web/src/item-details/comment.scss | 81 ++++++++
web/src/item-details/itemDetails.scss | 145 ++++++++++++++
web/src/pages/ItemDetailsPage.test.tsx | 267 +++++++++++++++++++++++++
web/src/pages/ItemDetailsPage.tsx | 142 ++++++++++++-
6 files changed, 819 insertions(+), 5 deletions(-)
create mode 100644 web/src/item-details/Comment.test.tsx
create mode 100644 web/src/item-details/Comment.tsx
create mode 100644 web/src/item-details/comment.scss
create mode 100644 web/src/item-details/itemDetails.scss
create mode 100644 web/src/pages/ItemDetailsPage.test.tsx
diff --git a/web/src/item-details/Comment.test.tsx b/web/src/item-details/Comment.test.tsx
new file mode 100644
index 00000000..b6369bcb
--- /dev/null
+++ b/web/src/item-details/Comment.test.tsx
@@ -0,0 +1,140 @@
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { describe, expect, it } from 'vitest';
+
+import { Comment as CommentModel } from '../models/comment';
+import { Comment } from './Comment';
+
+function buildComment(overrides: Partial = {}): CommentModel {
+ return {
+ id: 1,
+ level: 0,
+ user: 'kate',
+ time: 1500000000,
+ time_ago: '2 hours ago',
+ content: 'Top level comment
',
+ comments: [],
+ ...overrides,
+ };
+}
+
+function renderComment(comment: CommentModel) {
+ return render(
+
+
+
+ );
+}
+
+describe('Comment', () => {
+ it('renders the meta line, the user link and the html content', () => {
+ const { container } = renderComment(buildComment());
+
+ expect(screen.getByText('[-]')).toHaveClass('collapse');
+ expect(screen.getByRole('link', { name: 'kate' })).toHaveAttribute('href', '/user/kate');
+ expect(screen.getByText('2 hours ago')).toHaveClass('time');
+
+ const commentText = container.querySelector('.comment-tree .comment-text');
+ expect(commentText).not.toBeNull();
+ expect(commentText?.innerHTML).toBe('Top level comment
');
+ expect(container.querySelector('.meta')).not.toHaveClass('meta-collapse');
+ });
+
+ it('renders nested comments recursively', () => {
+ const comment = buildComment({
+ content: 'level one',
+ comments: [
+ buildComment({
+ id: 2,
+ level: 1,
+ user: 'bob',
+ content: 'level two',
+ comments: [buildComment({ id: 3, level: 2, user: 'carol', content: 'level three' })],
+ }),
+ ],
+ });
+
+ const { container } = renderComment(comment);
+
+ expect(screen.getByText('level one')).toBeInTheDocument();
+ expect(screen.getByText('level two')).toBeInTheDocument();
+ expect(screen.getByText('level three')).toBeInTheDocument();
+ expect(container.querySelectorAll('.subtree')).toHaveLength(3);
+
+ const firstSubtree = container.querySelector('.subtree');
+ expect(within(firstSubtree as HTMLElement).getByRole('link', { name: 'bob' })).toBeInTheDocument();
+ expect(within(firstSubtree as HTMLElement).getByRole('link', { name: 'carol' })).toBeInTheDocument();
+ });
+
+ it('collapses and expands the comment content and its children', async () => {
+ const user = userEvent.setup();
+ const comment = buildComment({
+ content: 'parent',
+ comments: [buildComment({ id: 2, user: 'bob', content: 'child' })],
+ });
+
+ const { container } = renderComment(comment);
+
+ expect(screen.getByText('parent')).toBeVisible();
+ expect(screen.getByText('child')).toBeVisible();
+
+ await user.click(screen.getAllByText('[-]')[0]);
+
+ expect(screen.getByText('[+]')).toBeInTheDocument();
+ expect(container.querySelector('.meta')).toHaveClass('meta-collapse');
+ expect(screen.getByText('parent')).not.toBeVisible();
+ expect(screen.getByText('child')).not.toBeVisible();
+ expect(screen.getByRole('link', { name: 'kate' })).toBeVisible();
+
+ await user.click(screen.getByText('[+]'));
+
+ expect(screen.getAllByText('[-]')[0]).toBeInTheDocument();
+ expect(container.querySelector('.meta')).not.toHaveClass('meta-collapse');
+ expect(screen.getByText('parent')).toBeVisible();
+ });
+
+ it('collapses a child independently of its parent', async () => {
+ const user = userEvent.setup();
+ const comment = buildComment({
+ content: 'parent',
+ comments: [buildComment({ id: 2, user: 'bob', content: 'child' })],
+ });
+
+ renderComment(comment);
+
+ await user.click(screen.getAllByText('[-]')[1]);
+
+ expect(screen.getByText('parent')).toBeVisible();
+ expect(screen.getByText('child')).not.toBeVisible();
+ });
+
+ it('renders the deleted state instead of the comment body', () => {
+ const { container } = renderComment(
+ buildComment({ deleted: true, content: 'should not be rendered', user: 'ghost' })
+ );
+
+ expect(screen.getByText('[deleted]')).toHaveClass('collapse');
+ expect(container.querySelector('.deleted-meta')?.textContent).toBe('[deleted] | Comment Deleted');
+ expect(screen.queryByText('should not be rendered')).not.toBeInTheDocument();
+ expect(screen.queryByRole('link', { name: 'ghost' })).not.toBeInTheDocument();
+ expect(container.querySelector('.comment-tree')).toBeNull();
+ });
+
+ it('renders deleted children inside a live comment tree', () => {
+ const comment = buildComment({
+ content: 'parent',
+ comments: [buildComment({ id: 2, user: 'ghost', deleted: true, content: 'hidden' })],
+ });
+
+ renderComment(comment);
+
+ expect(screen.getByText('parent')).toBeInTheDocument();
+ expect(screen.getByText('[deleted]')).toBeInTheDocument();
+ expect(screen.queryByText('hidden')).not.toBeInTheDocument();
+ });
+});
diff --git a/web/src/item-details/Comment.tsx b/web/src/item-details/Comment.tsx
new file mode 100644
index 00000000..77e6e03d
--- /dev/null
+++ b/web/src/item-details/Comment.tsx
@@ -0,0 +1,49 @@
+import { useState } from 'react';
+import { NavLink } from 'react-router-dom';
+
+import { Comment as CommentModel } from '../models/comment';
+import './comment.scss';
+
+export interface CommentProps {
+ comment: CommentModel;
+}
+
+export function Comment({ comment }: CommentProps) {
+ const [collapse, setCollapse] = useState(false);
+
+ if (comment.deleted) {
+ return (
+
+
+ [deleted] | Comment Deleted
+
+
+ );
+ }
+
+ return (
+
+
+ setCollapse(!collapse)}>
+ [{collapse ? '+' : '-'}]
+ {' '}
+ {comment.user}
+ {comment.time_ago}
+
+
+
+
+
+ {comment.comments?.map((subComment) => (
+
+
+
+ ))}
+
+
+
+
+ );
+}
+
+export default Comment;
diff --git a/web/src/item-details/comment.scss b/web/src/item-details/comment.scss
new file mode 100644
index 00000000..05174846
--- /dev/null
+++ b/web/src/item-details/comment.scss
@@ -0,0 +1,81 @@
+@import '../styles/media';
+@import '../styles/theme_variables';
+
+.comment-list {
+ a {
+ font-weight: bold;
+ text-decoration: none;
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+
+ .meta {
+ font-size: 13px;
+ color: #696969;
+ font-weight: bold;
+ letter-spacing: 0.5px;
+ margin-bottom: 8px;
+ a {
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ .time {
+ padding-left: 5px;
+ }
+
+ @media #{$mobile-only} {
+ font-size: 14px;
+ margin-bottom: 10px;
+ .time {
+ padding: 0;
+ float: right;
+ }
+ }
+ }
+
+ .meta-collapse {
+ margin-bottom: 20px;
+ }
+
+ .deleted-meta {
+ font-size: 12px;
+ font-weight: bold;
+ letter-spacing: 0.5px;
+ margin: 30px 0;
+ a {
+ text-decoration: none;
+ }
+ }
+
+ .collapse {
+ font-size: 13px;
+ letter-spacing: 2px;
+ cursor: pointer;
+ }
+
+ .comment-tree {
+ margin-left: 24px;
+
+ @media #{$tablet-only} {
+ margin-left: 8px;
+ }
+ }
+
+ .comment-text {
+ font-size: 15px;
+ margin-top: 0;
+ margin-bottom: 20px;
+ word-wrap: break-word;
+ line-height: 1.5em;
+ }
+
+ .subtree {
+ margin-left: 0;
+ padding: 0;
+ list-style-type: none;
+ }
+}
diff --git a/web/src/item-details/itemDetails.scss b/web/src/item-details/itemDetails.scss
new file mode 100644
index 00000000..8748353d
--- /dev/null
+++ b/web/src/item-details/itemDetails.scss
@@ -0,0 +1,145 @@
+@import '../styles/media';
+@import '../styles/theme_variables';
+
+.main-content {
+ position: relative;
+ width: 100%;
+ min-height: 100vh;
+ -webkit-transition: opacity 0.2s ease;
+ transition: opacity 0.2s ease;
+ box-sizing: border-box;
+ padding: 8px 0;
+ z-index: 0;
+}
+
+.item {
+ box-sizing: border-box;
+ padding: 10px 40px 0 40px;
+ z-index: 0;
+
+ @media #{$tablet-only} {
+ padding: 10px 20px 0 40px;
+ }
+
+ @media #{$mobile-only} {
+ padding: 110px 15px 0 15px;
+ }
+
+ .head-margin {
+ margin-bottom: 15px;
+ }
+
+ p {
+ margin: 2px 0;
+ }
+
+ .subject {
+ word-wrap: break-word;
+ margin-top: 20px;
+ }
+
+ a {
+ cursor: pointer;
+ text-decoration: none;
+ }
+
+ @media #{$mobile-only} {
+ .laptop {
+ display: none;
+ }
+ }
+
+ @media #{$laptop-only} {
+ .mobile {
+ display: none;
+ }
+ }
+
+ .title {
+ font-size: 16px;
+ font-family: Verdana, Geneva, sans-serif;
+
+ @media #{$mobile-only} {
+ font-size: 15px;
+ }
+ }
+
+ .title-block {
+ text-align: center;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+ margin: 0 75px;
+ }
+
+ @media #{$mobile-only} {
+ .back-button {
+ position: absolute;
+ top: 52%;
+ width: 0.6rem;
+ height: 0.6rem;
+ background: transparent;
+ box-shadow: 0 0 0 lightgray;
+ transition: all 200ms ease;
+ left: 4%;
+ transform: translate3d(0, -50%, 0) rotate(-135deg);
+ }
+ }
+
+ .subtext {
+ font-size: 12px;
+ font-weight: bold;
+ letter-spacing: 0.5px;
+
+ a {
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+
+ .domain {
+ letter-spacing: 0.5px;
+ }
+
+ .item-details {
+ padding: 10px;
+ }
+
+ .item-header {
+ padding-bottom: 10px;
+
+ @media #{$mobile-only} {
+ padding: 10px 0 10px 0;
+ position: fixed;
+ width: 100%;
+ left: 0;
+ top: 62px;
+ }
+ }
+
+ .pollResults {
+ margin-bottom: 1em;
+ }
+
+ .pollContent {
+ * {
+ padding-bottom: 0;
+ margin-bottom: -1em;
+ margin-top: 1em;
+ }
+ .pollBar {
+ height: 10px;
+ margin-bottom: 1em;
+ }
+ }
+
+ ul {
+ list-style-type: none;
+ padding: 10px 0;
+ }
+
+ li {
+ display: list-item;
+ }
+}
diff --git a/web/src/pages/ItemDetailsPage.test.tsx b/web/src/pages/ItemDetailsPage.test.tsx
new file mode 100644
index 00000000..272f7be8
--- /dev/null
+++ b/web/src/pages/ItemDetailsPage.test.tsx
@@ -0,0 +1,267 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { fetchItemContent } from '../api/hackerNews';
+import { SettingsProvider } from '../context/SettingsContext';
+import { Comment } from '../models/comment';
+import { Story } from '../models/story';
+import { stubMatchMedia } from '../testUtils/matchMedia';
+import { ItemDetailsPage } from './ItemDetailsPage';
+
+vi.mock('../api/hackerNews', () => ({
+ fetchItemContent: vi.fn(),
+}));
+
+const fetchItemContentMock = vi.mocked(fetchItemContent);
+
+function buildComment(overrides: Partial = {}): Comment {
+ return {
+ id: 100,
+ level: 0,
+ user: 'kate',
+ time: 1500000000,
+ time_ago: '1 hour ago',
+ content: 'a comment',
+ comments: [],
+ ...overrides,
+ };
+}
+
+function buildStory(overrides: Partial = {}): Story {
+ return {
+ id: 42,
+ title: 'A React story',
+ points: 120,
+ user: 'alice',
+ time: 1500000000,
+ time_ago: '3 hours ago',
+ type: 'story',
+ url: 'https://example.com/story',
+ domain: 'example.com',
+ comments: [],
+ comments_count: 2,
+ ...overrides,
+ };
+}
+
+function GoToItem({ id }: { id: number }) {
+ const navigate = useNavigate();
+ return navigate(`/item/${id}`)}>go ;
+}
+
+function renderPage(initialEntries: string[] = ['/item/42'], initialIndex?: number) {
+ return render(
+
+
+
+ news feed} />
+ } />
+
+
+
+ );
+}
+
+describe('ItemDetailsPage', () => {
+ beforeEach(() => {
+ stubMatchMedia(false);
+ vi.spyOn(window, 'scrollTo').mockImplementation(() => undefined);
+ fetchItemContentMock.mockReset();
+ });
+
+ it('shows the loader while the item is being fetched', () => {
+ fetchItemContentMock.mockReturnValue(new Promise(() => undefined));
+
+ const { container } = renderPage();
+
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
+ expect(container.querySelector('.main-content .loading-section')).not.toBeNull();
+ expect(container.querySelector('.item')).toBeNull();
+ });
+
+ it('fetches the item id taken from the route and scrolls to the top', async () => {
+ fetchItemContentMock.mockResolvedValue(buildStory());
+
+ renderPage(['/item/42']);
+
+ await screen.findAllByText('A React story');
+ expect(fetchItemContentMock).toHaveBeenCalledWith(42);
+ expect(window.scrollTo).toHaveBeenCalledWith(0, 0);
+ });
+
+ it('shows the error message when the item cannot be loaded', async () => {
+ fetchItemContentMock.mockRejectedValue(new Error('offline'));
+
+ const { container } = renderPage();
+
+ expect(await screen.findByText('Could not load item comments.')).toBeInTheDocument();
+ expect(container.querySelector('.loading-section')).toBeNull();
+ expect(container.querySelector('.item')).toBeNull();
+ });
+
+ it('renders the mobile and laptop headers for a story with an external url', async () => {
+ fetchItemContentMock.mockResolvedValue(buildStory({ content: 'story body
' }));
+
+ const { container } = renderPage();
+ await screen.findAllByText('A React story');
+
+ const mobileHeader = container.querySelector('.mobile.item-header') as HTMLElement;
+
+ const mobileTitle = mobileHeader.querySelector('a.title');
+ expect(mobileTitle).toHaveAttribute('href', 'https://example.com/story');
+ expect(mobileTitle).not.toHaveAttribute('target');
+ expect(mobileTitle).not.toHaveAttribute('rel');
+ expect(mobileHeader.querySelector('.title-block .back-button')).not.toBeNull();
+
+ const laptopHeader = container.querySelector('.laptop') as HTMLElement;
+ expect(laptopHeader).toHaveClass('item-header');
+ expect(laptopHeader).toHaveClass('head-margin');
+ expect(laptopHeader.querySelector('a.title')).toHaveAttribute('href', 'https://example.com/story');
+ expect(laptopHeader.querySelector('.domain')?.textContent).toBe('(example.com)');
+
+ const subtext = laptopHeader.querySelector('.subtext') as HTMLElement;
+ expect(subtext.textContent).toContain('120 points by');
+ expect(subtext.querySelector('a[href="/user/alice"]')).not.toBeNull();
+ expect(subtext.querySelector('.item-details')?.textContent).toContain('3 hours ago');
+ expect(subtext.querySelector('a[href="/item/42"]')?.textContent).toBe('2 comments');
+
+ expect(container.querySelector('.subject')?.innerHTML).toBe('story body
');
+ });
+
+ it('opens the story link in a new tab when the setting is enabled', async () => {
+ localStorage.setItem('openLinkInNewTab', 'true');
+ fetchItemContentMock.mockResolvedValue(buildStory());
+
+ const { container } = renderPage();
+ await screen.findAllByText('A React story');
+
+ container.querySelectorAll('a.title').forEach((title) => {
+ expect(title).toHaveAttribute('target', '_blank');
+ expect(title).toHaveAttribute('rel', 'noopener');
+ });
+ });
+
+ it('links the title to the item itself when the story has no external url', async () => {
+ fetchItemContentMock.mockResolvedValue(
+ buildStory({ url: 'item?id=42', domain: undefined, comments_count: 0, content: undefined })
+ );
+
+ const { container } = renderPage();
+ await screen.findAllByText('A React story');
+
+ container.querySelectorAll('a.title').forEach((title) => {
+ expect(title).toHaveAttribute('href', '/item/42');
+ });
+ expect(container.querySelector('.domain')).toBeNull();
+ expect(container.querySelector('.laptop')).not.toHaveClass('item-header');
+ expect(container.querySelector('.laptop')).not.toHaveClass('head-margin');
+ expect(container.querySelector('.subtext a[href="/item/42"]')?.textContent).toBe('discuss');
+ expect(container.querySelector('.subject')?.innerHTML).toBe('');
+ });
+
+ it('hides the points and comment count for job postings', async () => {
+ fetchItemContentMock.mockResolvedValue(buildStory({ type: 'job', comments_count: 0 }));
+
+ const { container } = renderPage();
+ await screen.findAllByText('A React story');
+
+ const subtext = container.querySelector('.laptop .subtext') as HTMLElement;
+ expect(subtext.textContent?.trim()).toBe('3 hours ago');
+ expect(subtext.querySelector('.item-details')).toBeNull();
+ expect(subtext.querySelector('a')).toBeNull();
+ expect(container.querySelector('.laptop')).toHaveClass('item-header');
+ });
+
+ it('renders poll results with bars sized from the vote share', async () => {
+ fetchItemContentMock.mockResolvedValue(
+ buildStory({
+ type: 'poll',
+ poll: [
+ { points: 30, content: 'Option A
' },
+ { points: 10, content: 'Option B
' },
+ ],
+ poll_votes_count: 40,
+ })
+ );
+
+ const { container } = renderPage();
+ await screen.findAllByText('A React story');
+
+ const pollContents = container.querySelectorAll('.pollResults .pollContent');
+ expect(pollContents).toHaveLength(2);
+ expect(pollContents[0].textContent).toContain('Option A');
+ expect(pollContents[0].querySelector('.subtext')?.textContent).toBe('30 points');
+ expect(pollContents[0].querySelector('.pollBar')?.style.width).toBe('75%');
+ expect(pollContents[1].querySelector('.subtext')?.textContent).toBe('10 points');
+ expect(pollContents[1].querySelector('.pollBar')?.style.width).toBe('25%');
+ });
+
+ it('does not render poll results for a regular story', async () => {
+ fetchItemContentMock.mockResolvedValue(buildStory());
+
+ const { container } = renderPage();
+ await screen.findAllByText('A React story');
+
+ expect(container.querySelector('.pollResults')).toBeNull();
+ });
+
+ it('renders the comment list, including nested comments', async () => {
+ fetchItemContentMock.mockResolvedValue(
+ buildStory({
+ comments: [
+ buildComment({
+ id: 100,
+ content: 'first comment',
+ comments: [buildComment({ id: 101, user: 'bob', content: 'nested reply' })],
+ }),
+ buildComment({ id: 102, user: 'carol', content: 'second comment' }),
+ ],
+ })
+ );
+
+ const { container } = renderPage();
+ await screen.findAllByText('A React story');
+
+ expect(container.querySelectorAll('.comment-list > li')).toHaveLength(2);
+ expect(screen.getByText('first comment')).toBeInTheDocument();
+ expect(screen.getByText('nested reply')).toBeInTheDocument();
+ expect(screen.getByText('second comment')).toBeInTheDocument();
+ });
+
+ it('goes back in history when the back button is clicked', async () => {
+ const user = userEvent.setup();
+ fetchItemContentMock.mockResolvedValue(buildStory());
+
+ const { container } = renderPage(['/', '/item/42'], 1);
+ await screen.findAllByText('A React story');
+
+ await user.click(container.querySelector('.back-button') as HTMLElement);
+
+ expect(await screen.findByText('news feed')).toBeInTheDocument();
+ });
+
+ it('refetches when the route id changes', async () => {
+ const user = userEvent.setup();
+ fetchItemContentMock.mockResolvedValue(buildStory());
+
+ render(
+
+
+
+
+ } />
+
+
+
+ );
+ await screen.findAllByText('A React story');
+
+ fetchItemContentMock.mockResolvedValue(buildStory({ id: 7, title: 'Another story' }));
+ await user.click(screen.getByRole('button', { name: 'go' }));
+
+ await screen.findAllByText('Another story');
+ expect(fetchItemContentMock).toHaveBeenLastCalledWith(7);
+ });
+});
diff --git a/web/src/pages/ItemDetailsPage.tsx b/web/src/pages/ItemDetailsPage.tsx
index dcde57c0..a0ce996b 100644
--- a/web/src/pages/ItemDetailsPage.tsx
+++ b/web/src/pages/ItemDetailsPage.tsx
@@ -1,9 +1,141 @@
-/**
- * Placeholder for the ported `ItemDetailsComponent`, implemented in Phase 2c.
- * The item id comes from the `/item/:id` route via `useParams`.
- */
+import { useEffect, useState } from 'react';
+import { NavLink, useNavigate, useParams } from 'react-router-dom';
+
+import { fetchItemContent } from '../api/hackerNews';
+import { ErrorMessage } from '../components/ErrorMessage';
+import { Loader } from '../components/Loader';
+import { useSettings } from '../context/SettingsContext';
+import { Comment } from '../item-details/Comment';
+import { Story } from '../models/story';
+import { formatCommentCount } from '../utils/formatCommentCount';
+import '../item-details/itemDetails.scss';
+
export function ItemDetailsPage() {
- return null;
+ const { id } = useParams();
+ const navigate = useNavigate();
+ const { settings } = useSettings();
+ const [item, setItem] = useState(null);
+ const [errorMessage, setErrorMessage] = useState('');
+
+ useEffect(() => {
+ let cancelled = false;
+ setItem(null);
+ setErrorMessage('');
+
+ fetchItemContent(Number(id)).then(
+ (story) => {
+ if (!cancelled) {
+ setItem(story);
+ }
+ },
+ () => {
+ if (!cancelled) {
+ setErrorMessage('Could not load item comments.');
+ }
+ }
+ );
+
+ window.scrollTo(0, 0);
+
+ return () => {
+ cancelled = true;
+ };
+ }, [id]);
+
+ if (!item) {
+ return (
+
+ {errorMessage === '' ? : }
+
+ );
+ }
+
+ const hasUrl = item.url !== undefined && item.url.indexOf('http') === 0;
+ const isJob = item.type === 'job';
+ const target = settings.openLinkInNewTab ? '_blank' : undefined;
+ const rel = settings.openLinkInNewTab ? 'noopener' : undefined;
+ const laptopClassName = [
+ 'laptop',
+ item.comments_count > 0 || isJob ? 'item-header' : '',
+ item.content ? 'head-margin' : '',
+ ]
+ .filter(Boolean)
+ .join(' ');
+
+ return (
+
+
+
+
+ navigate(-1)}>
+ {hasUrl ? (
+
+ {item.title}
+
+ ) : (
+
+ {item.title}
+
+ )}
+
+
+
+ {hasUrl ? (
+
+
+ {item.title}
+
+ {item.domain && ({item.domain}) }
+
+ ) : (
+
+
+ {item.title}
+
+
+ )}
+
+ {!isJob && (
+
+ {item.points} points by {item.user}
+
+ )}
+
+ {item.time_ago}
+ {!isJob && (
+
+ {' | '}
+ {formatCommentCount(item.comments_count)}
+
+ )}
+
+
+
+ {item.type === 'poll' && (
+
+ {item.poll?.map((pollResult, index) => (
+
+
+
{pollResult.points} points
+
+
+ ))}
+
+ )}
+
+
+ {item.comments?.map((comment) => (
+
+
+
+ ))}
+
+
+
+ );
}
export default ItemDetailsPage;
From 87bd830a74f9c899a42ec355241160b5668e0cff Mon Sep 17 00:00:00 2001
From: Devin AI
Date: Thu, 6 Aug 2026 12:52:42 +0000
Subject: [PATCH 5/5] =?UTF-8?q?feature:=20Phase=203=20=E2=80=94=20PWA=20se?=
=?UTF-8?q?rvice=20worker,=20Playwright=20e2e,=20full=20coverage,=20Angula?=
=?UTF-8?q?r=20removal?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds vite-plugin-pwa (Workbox app shell + NetworkFirst caching of the HN API), ports the index.html head and Google Analytics snippet, replaces the Protractor e2e with a fixture-driven Playwright suite, closes the remaining coverage gaps, and removes the Angular app now that the React port is at parity (the app moves from web/ to the repo root).
---
.github/workflows/ci.yml | 27 +-
.gitignore | 20 +-
.travis.yml | 23 -
CONTRIBUTING.md | 32 +-
README.md | 113 +-
angular.json | 134 -
browserslist | 12 -
e2e/app.spec.ts | 91 +
e2e/fixtures.ts | 84 +
e2e/protractor.conf.js | 32 -
e2e/src/app.e2e-spec.ts | 23 -
e2e/src/app.po.ts | 11 -
e2e/tsconfig.json | 13 -
web/eslint.config.js => eslint.config.js | 2 +-
index.html | 79 +
karma.conf.js | 32 -
ngsw-config.json | 29 -
web/package-lock.json => package-lock.json | 2839 +++++-
package.json | 111 +-
playwright.config.ts | 22 +
.../assets/icons/android-chrome-144x144.png | Bin
.../assets/icons/android-chrome-192x192.png | Bin
.../assets/icons/android-chrome-256x256.png | Bin
.../assets/icons/android-chrome-512x512.png | Bin
.../assets/icons/apple-touch-icon-120x120.png | Bin
.../assets/icons/apple-touch-icon-152x152.png | Bin
.../assets/icons/apple-touch-icon-180x180.png | Bin
.../assets/icons/apple-touch-icon-60x60.png | Bin
.../assets/icons/apple-touch-icon-76x76.png | Bin
.../assets/icons/apple-touch-icon.png | Bin
.../assets/icons/browserconfig.xml | 0
.../assets/icons/favicon-16x16.png | Bin
.../assets/icons/favicon-32x32.png | Bin
.../assets/icons/mstile-150x150.png | Bin
.../assets/icons/safari-pinned-tab.svg | 0
{src => public}/assets/images/cog.svg | 0
{src => public}/assets/images/logo-header.png | Bin
{src => public}/assets/images/logo.svg | 0
{src => public}/favicon.ico | Bin
{web/src => src}/App.scss | 0
{web/src => src}/App.test.tsx | 0
{web/src => src}/App.tsx | 0
{web/src => src}/api/hackerNews.test.ts | 0
{web/src => src}/api/hackerNews.ts | 0
src/app/app.component.html | 8 -
src/app/app.component.scss | 24 -
src/app/app.component.ts | 31 -
src/app/app.module.ts | 32 -
src/app/app.routes.ts | 43 -
src/app/core/core.module.ts | 13 -
src/app/core/footer/footer.component.html | 3 -
src/app/core/footer/footer.component.scss | 23 -
src/app/core/footer/footer.component.ts | 15 -
src/app/core/header/header.component.html | 25 -
src/app/core/header/header.component.scss | 149 -
src/app/core/header/header.component.ts | 28 -
src/app/core/settings/settings.component.html | 83 -
src/app/core/settings/settings.component.scss | 74 -
src/app/core/settings/settings.component.ts | 40 -
src/app/feeds/feed/feed.component.html | 24 -
src/app/feeds/feed/feed.component.scss | 108 -
src/app/feeds/feed/feed.component.ts | 49 -
src/app/feeds/item/item.component.html | 39 -
src/app/feeds/item/item.component.scss | 68 -
src/app/feeds/item/item.component.ts | 26 -
.../comment/comment.component.html | 22 -
.../comment/comment.component.scss | 85 -
.../item-details/comment/comment.component.ts | 19 -
.../item-details/item-details.component.html | 59 -
.../item-details/item-details.component.scss | 151 -
.../item-details/item-details.component.ts | 50 -
src/app/item-details/item-details.module.ts | 22 -
.../error-message.component.html | 12 -
.../error-message.component.scss | 115 -
.../error-message/error-message.component.ts | 16 -
.../components/loader/loader.component.html | 5 -
.../components/loader/loader.component.scss | 109 -
.../components/loader/loader.component.ts | 15 -
.../components/shared-components.module.ts | 11 -
src/app/shared/models/comment.ts | 10 -
src/app/shared/models/poll-result.ts | 4 -
src/app/shared/models/settings.ts | 7 -
src/app/shared/models/story.ts | 21 -
src/app/shared/models/user.ts | 8 -
src/app/shared/pipes/comment.pipe.ts | 15 -
src/app/shared/pipes/pipes.module.ts | 8 -
src/app/shared/scss/_media.scss | 3 -
src/app/shared/scss/_theme_variables.scss | 37 -
src/app/shared/scss/_themes.scss | 245 -
.../shared/services/hackernews-api.service.ts | 66 -
src/app/shared/services/settings.service.ts | 89 -
src/app/user/user.component.html | 19 -
src/app/user/user.component.scss | 89 -
src/app/user/user.component.ts | 37 -
src/app/user/user.module.ts | 21 -
.../components/ErrorMessage.test.tsx | 0
{web/src => src}/components/ErrorMessage.tsx | 0
{web/src => src}/components/Loader.test.tsx | 0
{web/src => src}/components/Loader.tsx | 0
{web/src => src}/components/errorMessage.scss | 0
{web/src => src}/components/loader.scss | 0
.../context/SettingsContext.test.tsx | 0
{web/src => src}/context/SettingsContext.tsx | 0
{web/src => src}/core/Footer.test.tsx | 0
{web/src => src}/core/Footer.tsx | 0
{web/src => src}/core/Header.test.tsx | 0
{web/src => src}/core/Header.tsx | 0
{web/src => src}/core/Settings.test.tsx | 11 +
{web/src => src}/core/Settings.tsx | 0
{web/src => src}/core/footer.scss | 0
{web/src => src}/core/header.scss | 0
{web/src => src}/core/settings.scss | 0
src/environments/environment.prod.ts | 3 -
src/environments/environment.ts | 16 -
{web/src => src}/feeds/Item.test.tsx | 0
{web/src => src}/feeds/Item.tsx | 0
{web/src => src}/feeds/feed.scss | 0
{web/src => src}/feeds/item.scss | 0
src/index.html | 78 -
.../src => src}/item-details/Comment.test.tsx | 0
{web/src => src}/item-details/Comment.tsx | 0
{web/src => src}/item-details/comment.scss | 0
.../src => src}/item-details/itemDetails.scss | 0
src/main.ts | 12 -
{web/src => src}/main.tsx | 0
src/manifest.json | 30 -
{web/src => src}/models/comment.ts | 0
src/{app/shared => }/models/feed-type.type.ts | 0
{web/src => src}/models/index.ts | 0
{web/src => src}/models/poll-result.ts | 0
{web/src => src}/models/settings.ts | 0
{web/src => src}/models/story.ts | 0
{web/src => src}/models/user.ts | 0
{web/src => src}/pages/FeedPage.test.tsx | 17 +
{web/src => src}/pages/FeedPage.tsx | 0
.../pages/ItemDetailsPage.test.tsx | 17 +
{web/src => src}/pages/ItemDetailsPage.tsx | 0
{web/src => src}/pages/UserPage.tsx | 0
src/polyfills.ts | 63 -
{web/src => src}/routes.test.tsx | 0
{web/src => src}/routes.tsx | 0
{web/src => src}/setupTests.ts | 0
src/styles.scss | 43 -
{web/src => src}/styles/_media.scss | 0
{web/src => src}/styles/_theme_variables.scss | 0
{web/src => src}/styles/_themes.scss | 0
{web/src => src}/styles/global.scss | 0
src/test.ts | 20 -
{web/src => src}/testUtils/matchMedia.ts | 0
{web/src => src}/user/UserPage.test.tsx | 15 +
{web/src => src}/user/user.scss | 0
.../utils/formatCommentCount.test.ts | 0
{web/src => src}/utils/formatCommentCount.ts | 0
{web/src => src}/vite-env.d.ts | 0
tsconfig.app.json | 14 -
tsconfig.json | 39 +-
tsconfig.spec.json | 18 -
tslint.json | 92 -
vite.config.ts | 66 +
web/.gitignore | 5 -
web/index.html | 16 -
web/package.json | 52 -
.../assets/icons/android-chrome-144x144.png | Bin 29992 -> 0 bytes
.../assets/icons/android-chrome-192x192.png | Bin 5033 -> 0 bytes
.../assets/icons/android-chrome-256x256.png | Bin 6756 -> 0 bytes
.../assets/icons/android-chrome-512x512.png | Bin 30053 -> 0 bytes
.../assets/icons/apple-touch-icon-120x120.png | Bin 2629 -> 0 bytes
.../assets/icons/apple-touch-icon-152x152.png | Bin 3304 -> 0 bytes
.../assets/icons/apple-touch-icon-180x180.png | Bin 3846 -> 0 bytes
.../assets/icons/apple-touch-icon-60x60.png | Bin 1699 -> 0 bytes
.../assets/icons/apple-touch-icon-76x76.png | Bin 1993 -> 0 bytes
web/public/assets/icons/apple-touch-icon.png | Bin 3846 -> 0 bytes
web/public/assets/icons/browserconfig.xml | 9 -
web/public/assets/icons/favicon-16x16.png | Bin 694 -> 0 bytes
web/public/assets/icons/favicon-32x32.png | Bin 1371 -> 0 bytes
web/public/assets/icons/mstile-150x150.png | Bin 3656 -> 0 bytes
web/public/assets/icons/safari-pinned-tab.svg | 1 -
web/public/assets/images/cog.svg | 1 -
web/public/assets/images/logo-header.png | Bin 4109 -> 0 bytes
web/public/assets/images/logo.svg | 1 -
web/public/favicon.ico | Bin 5430 -> 0 bytes
web/src/models/feed-type.type.ts | 1 -
web/tsconfig.json | 21 -
web/vite.config.ts | 19 -
yarn.lock | 8973 -----------------
185 files changed, 3327 insertions(+), 12330 deletions(-)
delete mode 100644 .travis.yml
delete mode 100644 angular.json
delete mode 100644 browserslist
create mode 100644 e2e/app.spec.ts
create mode 100644 e2e/fixtures.ts
delete mode 100644 e2e/protractor.conf.js
delete mode 100644 e2e/src/app.e2e-spec.ts
delete mode 100644 e2e/src/app.po.ts
delete mode 100644 e2e/tsconfig.json
rename web/eslint.config.js => eslint.config.js (93%)
create mode 100644 index.html
delete mode 100644 karma.conf.js
delete mode 100644 ngsw-config.json
rename web/package-lock.json => package-lock.json (71%)
create mode 100644 playwright.config.ts
rename {src => public}/assets/icons/android-chrome-144x144.png (100%)
rename {src => public}/assets/icons/android-chrome-192x192.png (100%)
rename {src => public}/assets/icons/android-chrome-256x256.png (100%)
rename {src => public}/assets/icons/android-chrome-512x512.png (100%)
rename {src => public}/assets/icons/apple-touch-icon-120x120.png (100%)
rename {src => public}/assets/icons/apple-touch-icon-152x152.png (100%)
rename {src => public}/assets/icons/apple-touch-icon-180x180.png (100%)
rename {src => public}/assets/icons/apple-touch-icon-60x60.png (100%)
rename {src => public}/assets/icons/apple-touch-icon-76x76.png (100%)
rename {src => public}/assets/icons/apple-touch-icon.png (100%)
rename {src => public}/assets/icons/browserconfig.xml (100%)
rename {src => public}/assets/icons/favicon-16x16.png (100%)
rename {src => public}/assets/icons/favicon-32x32.png (100%)
rename {src => public}/assets/icons/mstile-150x150.png (100%)
rename {src => public}/assets/icons/safari-pinned-tab.svg (100%)
rename {src => public}/assets/images/cog.svg (100%)
rename {src => public}/assets/images/logo-header.png (100%)
rename {src => public}/assets/images/logo.svg (100%)
rename {src => public}/favicon.ico (100%)
rename {web/src => src}/App.scss (100%)
rename {web/src => src}/App.test.tsx (100%)
rename {web/src => src}/App.tsx (100%)
rename {web/src => src}/api/hackerNews.test.ts (100%)
rename {web/src => src}/api/hackerNews.ts (100%)
delete mode 100644 src/app/app.component.html
delete mode 100644 src/app/app.component.scss
delete mode 100644 src/app/app.component.ts
delete mode 100644 src/app/app.module.ts
delete mode 100644 src/app/app.routes.ts
delete mode 100644 src/app/core/core.module.ts
delete mode 100644 src/app/core/footer/footer.component.html
delete mode 100644 src/app/core/footer/footer.component.scss
delete mode 100644 src/app/core/footer/footer.component.ts
delete mode 100644 src/app/core/header/header.component.html
delete mode 100644 src/app/core/header/header.component.scss
delete mode 100644 src/app/core/header/header.component.ts
delete mode 100644 src/app/core/settings/settings.component.html
delete mode 100644 src/app/core/settings/settings.component.scss
delete mode 100644 src/app/core/settings/settings.component.ts
delete mode 100644 src/app/feeds/feed/feed.component.html
delete mode 100644 src/app/feeds/feed/feed.component.scss
delete mode 100644 src/app/feeds/feed/feed.component.ts
delete mode 100644 src/app/feeds/item/item.component.html
delete mode 100644 src/app/feeds/item/item.component.scss
delete mode 100644 src/app/feeds/item/item.component.ts
delete mode 100644 src/app/item-details/comment/comment.component.html
delete mode 100644 src/app/item-details/comment/comment.component.scss
delete mode 100644 src/app/item-details/comment/comment.component.ts
delete mode 100644 src/app/item-details/item-details.component.html
delete mode 100644 src/app/item-details/item-details.component.scss
delete mode 100644 src/app/item-details/item-details.component.ts
delete mode 100644 src/app/item-details/item-details.module.ts
delete mode 100644 src/app/shared/components/error-message/error-message.component.html
delete mode 100644 src/app/shared/components/error-message/error-message.component.scss
delete mode 100644 src/app/shared/components/error-message/error-message.component.ts
delete mode 100644 src/app/shared/components/loader/loader.component.html
delete mode 100644 src/app/shared/components/loader/loader.component.scss
delete mode 100644 src/app/shared/components/loader/loader.component.ts
delete mode 100644 src/app/shared/components/shared-components.module.ts
delete mode 100644 src/app/shared/models/comment.ts
delete mode 100644 src/app/shared/models/poll-result.ts
delete mode 100644 src/app/shared/models/settings.ts
delete mode 100644 src/app/shared/models/story.ts
delete mode 100644 src/app/shared/models/user.ts
delete mode 100644 src/app/shared/pipes/comment.pipe.ts
delete mode 100644 src/app/shared/pipes/pipes.module.ts
delete mode 100644 src/app/shared/scss/_media.scss
delete mode 100644 src/app/shared/scss/_theme_variables.scss
delete mode 100644 src/app/shared/scss/_themes.scss
delete mode 100644 src/app/shared/services/hackernews-api.service.ts
delete mode 100644 src/app/shared/services/settings.service.ts
delete mode 100644 src/app/user/user.component.html
delete mode 100644 src/app/user/user.component.scss
delete mode 100644 src/app/user/user.component.ts
delete mode 100644 src/app/user/user.module.ts
rename {web/src => src}/components/ErrorMessage.test.tsx (100%)
rename {web/src => src}/components/ErrorMessage.tsx (100%)
rename {web/src => src}/components/Loader.test.tsx (100%)
rename {web/src => src}/components/Loader.tsx (100%)
rename {web/src => src}/components/errorMessage.scss (100%)
rename {web/src => src}/components/loader.scss (100%)
rename {web/src => src}/context/SettingsContext.test.tsx (100%)
rename {web/src => src}/context/SettingsContext.tsx (100%)
rename {web/src => src}/core/Footer.test.tsx (100%)
rename {web/src => src}/core/Footer.tsx (100%)
rename {web/src => src}/core/Header.test.tsx (100%)
rename {web/src => src}/core/Header.tsx (100%)
rename {web/src => src}/core/Settings.test.tsx (92%)
rename {web/src => src}/core/Settings.tsx (100%)
rename {web/src => src}/core/footer.scss (100%)
rename {web/src => src}/core/header.scss (100%)
rename {web/src => src}/core/settings.scss (100%)
delete mode 100644 src/environments/environment.prod.ts
delete mode 100644 src/environments/environment.ts
rename {web/src => src}/feeds/Item.test.tsx (100%)
rename {web/src => src}/feeds/Item.tsx (100%)
rename {web/src => src}/feeds/feed.scss (100%)
rename {web/src => src}/feeds/item.scss (100%)
delete mode 100644 src/index.html
rename {web/src => src}/item-details/Comment.test.tsx (100%)
rename {web/src => src}/item-details/Comment.tsx (100%)
rename {web/src => src}/item-details/comment.scss (100%)
rename {web/src => src}/item-details/itemDetails.scss (100%)
delete mode 100644 src/main.ts
rename {web/src => src}/main.tsx (100%)
delete mode 100644 src/manifest.json
rename {web/src => src}/models/comment.ts (100%)
rename src/{app/shared => }/models/feed-type.type.ts (100%)
rename {web/src => src}/models/index.ts (100%)
rename {web/src => src}/models/poll-result.ts (100%)
rename {web/src => src}/models/settings.ts (100%)
rename {web/src => src}/models/story.ts (100%)
rename {web/src => src}/models/user.ts (100%)
rename {web/src => src}/pages/FeedPage.test.tsx (91%)
rename {web/src => src}/pages/FeedPage.tsx (100%)
rename {web/src => src}/pages/ItemDetailsPage.test.tsx (94%)
rename {web/src => src}/pages/ItemDetailsPage.tsx (100%)
rename {web/src => src}/pages/UserPage.tsx (100%)
delete mode 100644 src/polyfills.ts
rename {web/src => src}/routes.test.tsx (100%)
rename {web/src => src}/routes.tsx (100%)
rename {web/src => src}/setupTests.ts (100%)
delete mode 100644 src/styles.scss
rename {web/src => src}/styles/_media.scss (100%)
rename {web/src => src}/styles/_theme_variables.scss (100%)
rename {web/src => src}/styles/_themes.scss (100%)
rename {web/src => src}/styles/global.scss (100%)
delete mode 100644 src/test.ts
rename {web/src => src}/testUtils/matchMedia.ts (100%)
rename {web/src => src}/user/UserPage.test.tsx (87%)
rename {web/src => src}/user/user.scss (100%)
rename {web/src => src}/utils/formatCommentCount.test.ts (100%)
rename {web/src => src}/utils/formatCommentCount.ts (100%)
rename {web/src => src}/vite-env.d.ts (100%)
delete mode 100644 tsconfig.app.json
delete mode 100644 tsconfig.spec.json
delete mode 100644 tslint.json
create mode 100644 vite.config.ts
delete mode 100644 web/.gitignore
delete mode 100644 web/index.html
delete mode 100644 web/package.json
delete mode 100644 web/public/assets/icons/android-chrome-144x144.png
delete mode 100644 web/public/assets/icons/android-chrome-192x192.png
delete mode 100644 web/public/assets/icons/android-chrome-256x256.png
delete mode 100644 web/public/assets/icons/android-chrome-512x512.png
delete mode 100644 web/public/assets/icons/apple-touch-icon-120x120.png
delete mode 100644 web/public/assets/icons/apple-touch-icon-152x152.png
delete mode 100644 web/public/assets/icons/apple-touch-icon-180x180.png
delete mode 100644 web/public/assets/icons/apple-touch-icon-60x60.png
delete mode 100644 web/public/assets/icons/apple-touch-icon-76x76.png
delete mode 100644 web/public/assets/icons/apple-touch-icon.png
delete mode 100644 web/public/assets/icons/browserconfig.xml
delete mode 100644 web/public/assets/icons/favicon-16x16.png
delete mode 100644 web/public/assets/icons/favicon-32x32.png
delete mode 100644 web/public/assets/icons/mstile-150x150.png
delete mode 100644 web/public/assets/icons/safari-pinned-tab.svg
delete mode 100755 web/public/assets/images/cog.svg
delete mode 100644 web/public/assets/images/logo-header.png
delete mode 100644 web/public/assets/images/logo.svg
delete mode 100644 web/public/favicon.ico
delete mode 100644 web/src/models/feed-type.type.ts
delete mode 100644 web/tsconfig.json
delete mode 100644 web/vite.config.ts
delete mode 100644 yarn.lock
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6ba349a9..f6630c52 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,20 +6,35 @@ on:
pull_request:
jobs:
- web:
- name: Lint, test and build the React app
+ build:
+ name: Lint, test and build
runs-on: ubuntu-latest
- defaults:
- run:
- working-directory: web
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- cache-dependency-path: web/package-lock.json
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run build
+
+ e2e:
+ name: End-to-end tests
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+ - run: npm ci
+ - run: npx playwright install --with-deps chromium
+ - run: npm run test:e2e
+ - uses: actions/upload-artifact@v4
+ if: failure()
+ with:
+ name: playwright-report
+ path: playwright-report/
+ retention-days: 7
diff --git a/.gitignore b/.gitignore
index f4f46a5f..baad8e4d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,17 +2,18 @@
# compiled output
/dist
+/dev-dist
/tmp
-/out-tsc
-# Only exists if Bazel was run
-/bazel-out
# dependencies
/node_modules
-# profiling files
-chrome-profiler-events.json
-speed-measure-plugin.json
+# tests
+/coverage
+/test-results
+/playwright-report
+/blob-report
+/playwright/.cache
# IDEs and editors
/.idea
@@ -32,14 +33,9 @@ speed-measure-plugin.json
.history/*
# misc
-/.sass-cache
-/connect.lock
-/coverage
-/libpeerconnection.log
npm-debug.log
yarn-error.log
-testem.log
-/typings
+*.local
# System Files
.DS_Store
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index ff155b5f..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,23 +0,0 @@
-language: node_js
-node_js:
- - "6.9"
-
-branches:
- only:
- - master
-
-before_script:
- - npm install -g firebase-tools
- - npm install -g @angular/cli
-
-script:
- - npm run build
-
-after_success:
- - firebase use default
- - firebase deploy --token $FIREBASE_TOKEN
-
-notifications:
- email:
- on_failure: change
- on_success: change
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 840cf824..7d862ed9 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,27 +1,27 @@
# Contributing
Thank you for your interest in contributing! Please feel free to put up a PR for any issue or feature request.
-Even if you have little to no experience with Angular, I'll be more than happy to help. :)
+Even if you have little to no experience with React, I'll be more than happy to help. :)
## Setup
1. Fork the repo
2. Clone your fork
3. Make a branch for your feature or bug fix
-4. If you don't have Angular CLI installed: `npm install -g angular-cli@latest`
-5. `ng init`
-6. Type `n` for each file to not overwrite any file changes
-7. Run `npm start` and open `localhost:4200` in a browser
-8. Work your magic
-9. Run `npm run build` or `npm run static-serve` to kick off a production build and make sure nothing is broken
-10. To test service worker changes:
- * `npm run build` to kick off a fresh build and update the `dist/` directory
- * `npm run precache` to generate the service worker file
- * `npm run static-serve` to load the application along with the service worker asset using [live-server](https://github.com/tapio/live-server)
-11. Add yourself to the [contributor's list](https://github.com/hdjirdeh/angular2-hn#contributors) in the README!
-12. Commit your changes and reference the issue you're addressing (for example: `git commit -am 'Commit message. Closes #5'`)
-13. Push your branch to your fork
-14. Create a pull request from your branch on your fork to `master` on this repo
-15. Have your branch get merged in! :star2:
+4. `npm ci`
+5. `npm run dev` and open `localhost:5173` in a browser
+6. Work your magic
+7. Before pushing, make sure nothing is broken:
+ * `npm run lint`
+ * `npm test` (add or update Vitest/React Testing Library tests for your change)
+ * `npm run build`
+ * `npm run test:e2e` for the Playwright suite (run `npx playwright install chromium` once beforehand)
+8. To test service worker changes, run `npm run build` followed by `npm run preview` — the service worker is only
+ generated for production builds
+9. Add yourself to the [contributor's list](https://github.com/hdjirdeh/angular2-hn#contributors) in the README!
+10. Commit your changes and reference the issue you're addressing (for example: `git commit -am 'Commit message. Closes #5'`)
+11. Push your branch to your fork
+12. Create a pull request from your branch on your fork to `master` on this repo
+13. Have your branch get merged in! :star2:
If you experience a problem at any point, please don't hesitate to file an issue or send me a message!
diff --git a/README.md b/README.md
index 8fca3ce7..3fa3c0ea 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@
- A progressive Hacker News client built with Angular
+ A progressive Hacker News client built with React
@@ -14,52 +14,80 @@
-
+
---
-## React migration (in progress)
-
-The app is being migrated from Angular to React 18 + TypeScript + Vite. The React app lives in [`web/`](/web) while the
-Angular app in `src/` stays buildable until parity is reached.
-
-```bash
-cd web
-npm ci
-npm run dev # dev server on http://localhost:5173
-npm run lint
-npm test
-npm run build
-```
-
----
-
:zap: **Fast:** Service Worker App Shell + Dynamic Content model to achieve faster load times with and without a network.
:iphone: **Responsive:** Completely responsive UI that can be installed to your mobile home screen to provide a native feel.
-:rocket: **Progressive:** [Lighthouse](https://github.com/GoogleChrome/lighthouse) score of 87/100.
+:rocket: **Progressive:** installable, offline-capable PWA.
-## Mobile Preview
+## Stack
-
-
-
+The app was originally written in Angular and has been rewritten in React with no change in behaviour:
-## Laptop Preview
+| | |
+| --- | --- |
+| UI | React 18 + TypeScript |
+| Build / dev server | Vite 5 |
+| Routing | React Router v6 |
+| Styling | SCSS (three themes: Default, Night, Black (AMOLED)) |
+| Unit / component tests | Vitest + React Testing Library |
+| End-to-end tests | Playwright |
+| PWA | `vite-plugin-pwa` (Workbox) |
+| Data | [node-hnapi](https://github.com/cheeaun/node-hnapi) over native `fetch` |
-
-
-
+## Getting started
-## Offline Support
+```bash
+npm ci
+npm run dev # dev server on http://localhost:5173
+npm run build # type-check and build to dist/
+npm run preview # serve the production build (service worker included)
+```
+
+## Tests
-This app uses [Workbox](https://workboxjs.org/) to generate a service worker as part of the build step to load quickly and work offline.
+```bash
+npm run lint # ESLint (with Prettier compatibility)
+npm test # Vitest unit and component tests
+npm run test:coverage # the same suite with a V8 coverage report
+npm run test:e2e # Playwright end-to-end tests against the production build
+```
+
+The Playwright suite stubs the Hacker News API with fixtures (`e2e/fixtures.ts`) so it is deterministic and can run
+offline. CI (`.github/workflows/ci.yml`) runs lint, unit tests, the build and the e2e suite on every pull request.
+
+## Project layout
+
+```
+src/
+ api/ fetch-based Hacker News API client
+ components/ shared presentational components (Loader, ErrorMessage)
+ context/ SettingsContext (theme, font size, list spacing, link behaviour)
+ core/ app chrome: Header, Footer, Settings modal
+ feeds/ feed item row
+ item-details/ recursive comment tree
+ models/ TypeScript models (Story, Comment, User, PollResult, Settings)
+ pages/ routed pages: FeedPage, ItemDetailsPage, UserPage
+ styles/ SCSS themes and shared variables/mixins
+ utils/ pure helpers (formatCommentCount)
+ routes.tsx route table mirroring the original Angular routes
+e2e/ Playwright specs and API fixtures
+```
+
+## Offline support
+
+`vite-plugin-pwa` generates a Workbox service worker at build time: the app shell is precached and every navigation
+falls back to `index.html`, while Hacker News API responses are served with a `NetworkFirst` strategy (24h expiry) so
+previously visited feeds and items keep working offline.
## Manifest
@@ -78,27 +106,26 @@ Current themes:
* Night
* Black (AMOLED)
-More to come!
+The theme follows `prefers-color-scheme` on first load and is then persisted to `localStorage`.
-## Areas of improvement
+## Mobile Preview
- - Realtime updating using the Firebase SDK (may need to add option to settings so service worker can still rely on REST endpoints)
- - Server side rendering
+
+
+
-Feel free to send me feedback on [twitter](https://twitter.com/hdjirdeh) or [file an issue](https://github.com/hdjirdeh/angular2-hn/issues/new)! Feature requests are always welcome.
+## Laptop Preview
-## Build process
+
+
+
-Note: This project has been ejected (with AOT + production settings) in order to customize Webpack configurations.
+## Areas of improvement
- - Clone or download the repo
- - `npm install`
- - `npm start` to run the application with webpack-dev-server or `npm build` to kick off a fresh build and update the output directory (`dist/`)
+ - Realtime updating using the Firebase SDK (may need to add option to settings so the service worker can still rely on REST endpoints)
+ - Server side rendering
-Note: Any Service Worker changes will not be reflected when you run the application locally in development. To test service worker changes:
- - `npm build`
- - `npm run precache` to generate the service worker file
- - `npm run static-serve` to load the application along with the service worker asset using [live-server](https://github.com/tapio/live-server)
+Feel free to send me feedback on [twitter](https://twitter.com/hdjirdeh) or [file an issue](https://github.com/hdjirdeh/angular2-hn/issues/new)! Feature requests are always welcome.
## Contributors
diff --git a/angular.json b/angular.json
deleted file mode 100644
index bae858c4..00000000
--- a/angular.json
+++ /dev/null
@@ -1,134 +0,0 @@
-{
- "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
- "version": 1,
- "newProjectRoot": "projects",
- "projects": {
- "angular-hnpwa": {
- "projectType": "application",
- "schematics": {
- "@schematics/angular:component": {
- "style": "scss"
- }
- },
- "root": "",
- "sourceRoot": "src",
- "prefix": "app",
- "architect": {
- "build": {
- "builder": "@angular-devkit/build-angular:browser",
- "options": {
- "aot": true,
- "outputPath": "dist/angular-hnpwa",
- "index": "src/index.html",
- "main": "src/main.ts",
- "polyfills": "src/polyfills.ts",
- "tsConfig": "tsconfig.app.json",
- "assets": [
- "src/favicon.ico",
- "src/assets",
- "src/manifest.json",
- "src/manifest.webmanifest"
- ],
- "styles": [
- "src/styles.scss"
- ],
- "scripts": []
- },
- "configurations": {
- "production": {
- "fileReplacements": [
- {
- "replace": "src/environments/environment.ts",
- "with": "src/environments/environment.prod.ts"
- }
- ],
- "optimization": true,
- "outputHashing": "all",
- "sourceMap": true,
- "extractCss": true,
- "namedChunks": true,
- "aot": true,
- "extractLicenses": true,
- "vendorChunk": false,
- "buildOptimizer": true,
- "budgets": [
- {
- "type": "initial",
- "maximumWarning": "2mb",
- "maximumError": "5mb"
- },
- {
- "type": "anyComponentStyle",
- "maximumWarning": "6kb"
- }
- ],
- "serviceWorker": true,
- "ngswConfigPath": "ngsw-config.json"
- }
- }
- },
- "serve": {
- "builder": "@angular-devkit/build-angular:dev-server",
- "options": {
- "browserTarget": "angular-hnpwa:build"
- },
- "configurations": {
- "production": {
- "browserTarget": "angular-hnpwa:build:production"
- }
- }
- },
- "extract-i18n": {
- "builder": "@angular-devkit/build-angular:extract-i18n",
- "options": {
- "browserTarget": "angular-hnpwa:build"
- }
- },
- "test": {
- "builder": "@angular-devkit/build-angular:karma",
- "options": {
- "main": "src/test.ts",
- "polyfills": "src/polyfills.ts",
- "tsConfig": "tsconfig.spec.json",
- "karmaConfig": "karma.conf.js",
- "assets": [
- "src/favicon.ico",
- "src/assets",
- "src/manifest.webmanifest"
- ],
- "styles": [
- "src/styles.scss"
- ],
- "scripts": []
- }
- },
- "lint": {
- "builder": "@angular-devkit/build-angular:tslint",
- "options": {
- "tsConfig": [
- "tsconfig.app.json",
- "tsconfig.spec.json",
- "e2e/tsconfig.json"
- ],
- "exclude": [
- "**/node_modules/**"
- ]
- }
- },
- "e2e": {
- "builder": "@angular-devkit/build-angular:protractor",
- "options": {
- "protractorConfig": "e2e/protractor.conf.js",
- "devServerTarget": "angular-hnpwa:serve"
- },
- "configurations": {
- "production": {
- "devServerTarget": "angular-hnpwa:serve:production"
- }
- }
- }
- }
- }
- },
- "defaultProject": "angular-hnpwa"
-}
diff --git a/browserslist b/browserslist
deleted file mode 100644
index 80848532..00000000
--- a/browserslist
+++ /dev/null
@@ -1,12 +0,0 @@
-# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
-# For additional information regarding the format and rule options, please see:
-# https://github.com/browserslist/browserslist#queries
-
-# You can see what browsers were selected by your queries by running:
-# npx browserslist
-
-> 0.5%
-last 2 versions
-Firefox ESR
-not dead
-not IE 9-11 # For IE 9-11 support, remove 'not'.
\ No newline at end of file
diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts
new file mode 100644
index 00000000..1521a3b7
--- /dev/null
+++ b/e2e/app.spec.ts
@@ -0,0 +1,91 @@
+import { expect, test } from '@playwright/test';
+
+import { mockHackerNewsApi } from './fixtures';
+
+test.beforeEach(async ({ page }) => {
+ await mockHackerNewsApi(page);
+});
+
+test('redirects the root url to the news feed and lists stories', async ({ page }) => {
+ await page.goto('/');
+
+ await expect(page).toHaveURL('/news/1');
+ await expect(page.locator('li.post')).toHaveCount(30);
+ await expect(page.locator('li.post').first().locator('a.title')).toHaveText('news story 1 (page 1)');
+});
+
+test('navigates between the feeds from the header', async ({ page }) => {
+ await page.goto('/news/1');
+
+ for (const [link, feedType] of [
+ ['new', 'newest'],
+ ['show', 'show'],
+ ['ask', 'ask'],
+ ['jobs', 'jobs'],
+ ]) {
+ await page.locator('.header-nav').getByRole('link', { name: link, exact: true }).click();
+ await expect(page).toHaveURL(`/${feedType}/1`);
+ await expect(page.locator('li.post').first().locator('a.title')).toHaveText(`${feedType} story 1 (page 1)`);
+ }
+
+ await expect(page.locator('p.job-header')).toBeVisible();
+});
+
+test('paginates the feed', async ({ page }) => {
+ await page.goto('/news/1');
+
+ await expect(page.locator('a.prev')).toHaveCount(0);
+ await page.locator('a.more').click();
+
+ await expect(page).toHaveURL('/news/2');
+ await expect(page.locator('ol')).toHaveAttribute('start', '31');
+ await expect(page.locator('a.prev')).toBeVisible();
+});
+
+test('opens an item and renders its nested comments', async ({ page }) => {
+ await page.goto('/news/1');
+
+ await page.locator('li.post').first().locator('.subtext-laptop a[href^="/item/"]').click();
+
+ await expect(page).toHaveURL(/\/item\/\d+$/);
+ await expect(page.locator('.laptop a.title')).toHaveText('An item with comments');
+ await expect(page.getByText('A top level comment')).toBeVisible();
+ await expect(page.getByText('A nested reply')).toBeVisible();
+
+ // Collapsing the top level comment hides its content and its replies.
+ await page.locator('.comment-list .collapse').first().click();
+ await expect(page.getByText('A nested reply')).toBeHidden();
+ await expect(page.locator('.comment-list .collapse').first()).toHaveText('[+]');
+});
+
+test('opens a user profile from a comment', async ({ page }) => {
+ await page.goto('/item/42');
+
+ await page.locator('.comment-list').getByRole('link', { name: 'commenter' }).click();
+
+ await expect(page).toHaveURL('/user/commenter');
+ await expect(page.locator('.profile .name')).toHaveText('commenter');
+ await expect(page.locator('.profile')).toContainText('4321 ★');
+ await expect(page.getByText('All about the author')).toBeVisible();
+});
+
+test('toggles the settings modal and applies a theme', async ({ page }) => {
+ await page.goto('/news/1');
+
+ await expect(page.locator('#popup1')).toHaveCount(0);
+ await page.locator('img.settings').click();
+ await expect(page.locator('#popup1')).toBeVisible();
+
+ await page.getByRole('radio', { name: 'Night' }).check();
+ await expect(page.locator('div.night')).toBeVisible();
+
+ await page.getByRole('radio', { name: 'Black (AMOLED)' }).check();
+ await expect(page.locator('div.amoledblack')).toBeVisible();
+
+ await page.locator('.popup .close').click();
+ await expect(page.locator('#popup1')).toHaveCount(0);
+
+ // The theme survives a reload because it is persisted in localStorage.
+ await page.reload();
+ await expect(page.locator('div.amoledblack')).toBeVisible();
+});
diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts
new file mode 100644
index 00000000..b60fb4fc
--- /dev/null
+++ b/e2e/fixtures.ts
@@ -0,0 +1,84 @@
+import { Page } from '@playwright/test';
+
+const API = 'https://node-hnapi.herokuapp.com';
+
+export function feedStories(feedType: string, page: number, count = 30) {
+ return Array.from({ length: count }, (_, index) => ({
+ id: page * 1000 + index,
+ title: `${feedType} story ${index + 1} (page ${page})`,
+ points: 10 + index,
+ user: `user${index}`,
+ time: 1600000000,
+ time_ago: `${index + 1} hours ago`,
+ type: 'story',
+ url: `https://example.com/${feedType}/${index}`,
+ domain: 'example.com',
+ comments_count: index,
+ }));
+}
+
+export const itemWithComments = {
+ id: 42,
+ title: 'An item with comments',
+ points: 123,
+ user: 'author',
+ time: 1600000000,
+ time_ago: '3 hours ago',
+ type: 'story',
+ url: 'https://example.com/an-item',
+ domain: 'example.com',
+ content: 'Item body
',
+ comments_count: 2,
+ comments: [
+ {
+ id: 1,
+ level: 0,
+ user: 'commenter',
+ time: 1600000001,
+ time_ago: '2 hours ago',
+ content: 'A top level comment
',
+ comments: [
+ {
+ id: 2,
+ level: 1,
+ user: 'replier',
+ time: 1600000002,
+ time_ago: '1 hour ago',
+ content: 'A nested reply
',
+ comments: [],
+ },
+ ],
+ },
+ ],
+};
+
+export const user = {
+ id: 'author',
+ created: 'October 22, 2010',
+ karma: 4321,
+ about: 'All about the author
',
+};
+
+/**
+ * Serves deterministic fixtures for every Hacker News API call so the e2e run
+ * never depends on the live API.
+ */
+export async function mockHackerNewsApi(page: Page) {
+ await page.route(`${API}/**`, async (route) => {
+ const url = new URL(route.request().url());
+ const [, resource, id] = url.pathname.split('/');
+ const pageNumber = Number(url.searchParams.get('page') ?? '1');
+
+ if (resource === 'item') {
+ await route.fulfill({ json: { ...itemWithComments, id: Number(id) } });
+ return;
+ }
+
+ if (resource === 'user') {
+ await route.fulfill({ json: { ...user, id: String(id) } });
+ return;
+ }
+
+ await route.fulfill({ json: feedStories(resource, pageNumber) });
+ });
+}
diff --git a/e2e/protractor.conf.js b/e2e/protractor.conf.js
deleted file mode 100644
index 73e4e680..00000000
--- a/e2e/protractor.conf.js
+++ /dev/null
@@ -1,32 +0,0 @@
-// @ts-check
-// Protractor configuration file, see link for more information
-// https://github.com/angular/protractor/blob/master/lib/config.ts
-
-const { SpecReporter } = require('jasmine-spec-reporter');
-
-/**
- * @type { import("protractor").Config }
- */
-exports.config = {
- allScriptsTimeout: 11000,
- specs: [
- './src/**/*.e2e-spec.ts'
- ],
- capabilities: {
- 'browserName': 'chrome'
- },
- directConnect: true,
- baseUrl: 'http://localhost:4200/',
- framework: 'jasmine',
- jasmineNodeOpts: {
- showColors: true,
- defaultTimeoutInterval: 30000,
- print: function() {}
- },
- onPrepare() {
- require('ts-node').register({
- project: require('path').join(__dirname, './tsconfig.json')
- });
- jasmine.getEnv().addReporter(new SpecReporter({ spec: { displayStacktrace: true } }));
- }
-};
\ No newline at end of file
diff --git a/e2e/src/app.e2e-spec.ts b/e2e/src/app.e2e-spec.ts
deleted file mode 100644
index 0897abab..00000000
--- a/e2e/src/app.e2e-spec.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { AppPage } from './app.po';
-import { browser, logging } from 'protractor';
-
-describe('workspace-project App', () => {
- let page: AppPage;
-
- beforeEach(() => {
- page = new AppPage();
- });
-
- it('should display welcome message', () => {
- page.navigateTo();
- expect(page.getTitleText()).toEqual('Welcome to angular-hnpwa!');
- });
-
- afterEach(async () => {
- // Assert that there are no errors emitted from the browser
- const logs = await browser.manage().logs().get(logging.Type.BROWSER);
- expect(logs).not.toContain(jasmine.objectContaining({
- level: logging.Level.SEVERE,
- } as logging.Entry));
- });
-});
diff --git a/e2e/src/app.po.ts b/e2e/src/app.po.ts
deleted file mode 100644
index 5776aa9e..00000000
--- a/e2e/src/app.po.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { browser, by, element } from 'protractor';
-
-export class AppPage {
- navigateTo() {
- return browser.get(browser.baseUrl) as Promise;
- }
-
- getTitleText() {
- return element(by.css('app-root h1')).getText() as Promise;
- }
-}
diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json
deleted file mode 100644
index 39b800f7..00000000
--- a/e2e/tsconfig.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "extends": "../tsconfig.json",
- "compilerOptions": {
- "outDir": "../out-tsc/e2e",
- "module": "commonjs",
- "target": "es5",
- "types": [
- "jasmine",
- "jasminewd2",
- "node"
- ]
- }
-}
diff --git a/web/eslint.config.js b/eslint.config.js
similarity index 93%
rename from web/eslint.config.js
rename to eslint.config.js
index 2f61ede7..2cd1abad 100644
--- a/web/eslint.config.js
+++ b/eslint.config.js
@@ -7,7 +7,7 @@ import reactRefresh from 'eslint-plugin-react-refresh';
import prettier from 'eslint-config-prettier';
export default tseslint.config(
- { ignores: ['dist', 'coverage', 'dev-dist'] },
+ { ignores: ['dist', 'coverage', 'dev-dist', 'playwright-report', 'test-results'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended, prettier],
files: ['**/*.{ts,tsx}'],
diff --git a/index.html b/index.html
new file mode 100644
index 00000000..d0d63244
--- /dev/null
+++ b/index.html
@@ -0,0 +1,79 @@
+
+
+
+
+ Angular 2 HN
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sorry, JavaScript needs to be enabled in order to run this application.
+
+
+
+
+
+
+
+
diff --git a/karma.conf.js b/karma.conf.js
deleted file mode 100644
index f8848956..00000000
--- a/karma.conf.js
+++ /dev/null
@@ -1,32 +0,0 @@
-// Karma configuration file, see link for more information
-// https://karma-runner.github.io/1.0/config/configuration-file.html
-
-module.exports = function (config) {
- config.set({
- basePath: '',
- frameworks: ['jasmine', '@angular-devkit/build-angular'],
- plugins: [
- require('karma-jasmine'),
- require('karma-chrome-launcher'),
- require('karma-jasmine-html-reporter'),
- require('karma-coverage-istanbul-reporter'),
- require('@angular-devkit/build-angular/plugins/karma')
- ],
- client: {
- clearContext: false // leave Jasmine Spec Runner output visible in browser
- },
- coverageIstanbulReporter: {
- dir: require('path').join(__dirname, './coverage/angular-hnpwa'),
- reports: ['html', 'lcovonly', 'text-summary'],
- fixWebpackSourcePaths: true
- },
- reporters: ['progress', 'kjhtml'],
- port: 9876,
- colors: true,
- logLevel: config.LOG_INFO,
- autoWatch: true,
- browsers: ['Chrome'],
- singleRun: false,
- restartOnFileChange: true
- });
-};
diff --git a/ngsw-config.json b/ngsw-config.json
deleted file mode 100644
index 23720c34..00000000
--- a/ngsw-config.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
- "$schema": "./node_modules/@angular/service-worker/config/schema.json",
- "index": "/index.html",
- "assetGroups": [
- {
- "name": "app",
- "installMode": "prefetch",
- "resources": {
- "files": [
- "/favicon.ico",
- "/index.html",
- "/*.css",
- "/*.js",
- "/manifest.webmanifest"
- ]
- }
- }, {
- "name": "assets",
- "installMode": "lazy",
- "updateMode": "prefetch",
- "resources": {
- "files": [
- "/assets/**",
- "/*.(eot|svg|cur|jpg|png|webp|gif|otf|ttf|woff|woff2|ani)"
- ]
- }
- }
- ]
-}
diff --git a/web/package-lock.json b/package-lock.json
similarity index 71%
rename from web/package-lock.json
rename to package-lock.json
index 9034567d..fe858adc 100644
--- a/web/package-lock.json
+++ b/package-lock.json
@@ -1,11 +1,11 @@
{
- "name": "react-hnpwa",
+ "name": "angular2-hn",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "react-hnpwa",
+ "name": "angular2-hn",
"version": "0.0.0",
"dependencies": {
"react": "^18.3.1",
@@ -14,10 +14,12 @@
},
"devDependencies": {
"@eslint/js": "^9.11.1",
+ "@playwright/test": "^1.62.1",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
+ "@types/node": "^20.19.43",
"@types/react": "^18.3.10",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
@@ -34,6 +36,7 @@
"typescript": "^5.5.4",
"typescript-eslint": "^8.7.0",
"vite": "^5.4.8",
+ "vite-plugin-pwa": "^0.20.5",
"vitest": "^2.1.1"
}
},
@@ -152,57 +155,1198 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@babel/helper-annotate-as-pure": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz",
+ "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-compilation-targets": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
- "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-create-class-features-plugin": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz",
+ "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.29.7",
+ "@babel/helper-member-expression-to-functions": "^7.29.7",
+ "@babel/helper-optimise-call-expression": "^7.29.7",
+ "@babel/helper-replace-supers": "^7.29.7",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-create-regexp-features-plugin": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.7.tgz",
+ "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.29.7",
+ "regexpu-core": "^6.3.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-define-polyfill-provider": {
+ "version": "0.6.8",
+ "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz",
+ "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-plugin-utils": "^7.28.6",
+ "debug": "^4.4.3",
+ "lodash.debounce": "^4.0.8",
+ "resolve": "^1.22.11"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/@babel/helper-define-polyfill-provider/node_modules/resolve": {
+ "version": "1.22.12",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-member-expression-to-functions": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz",
+ "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-optimise-call-expression": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz",
+ "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-remap-async-to-generator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz",
+ "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.29.7",
+ "@babel/helper-wrap-function": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-replace-supers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz",
+ "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-member-expression-to-functions": "^7.29.7",
+ "@babel/helper-optimise-call-expression": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-skip-transparent-expression-wrappers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz",
+ "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-wrap-function": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz",
+ "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
+ "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.8"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.29.7.tgz",
+ "integrity": "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.29.7.tgz",
+ "integrity": "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.29.7.tgz",
+ "integrity": "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.7.tgz",
+ "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.29.7.tgz",
+ "integrity": "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7",
+ "@babel/plugin-transform-optional-chaining": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.13.0"
+ }
+ },
+ "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.29.7.tgz",
+ "integrity": "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-proposal-private-property-in-object": {
+ "version": "7.21.0-placeholder-for-preset-env.2",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz",
+ "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-import-assertions": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.29.7.tgz",
+ "integrity": "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-import-attributes": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.29.7.tgz",
+ "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-syntax-unicode-sets-regex": {
+ "version": "7.18.6",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz",
+ "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.18.6",
+ "@babel/helper-plugin-utils": "^7.18.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-arrow-functions": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.29.7.tgz",
+ "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-async-generator-functions": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz",
+ "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-remap-async-to-generator": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-async-to-generator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz",
+ "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-remap-async-to-generator": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-block-scoped-functions": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.29.7.tgz",
+ "integrity": "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-block-scoping": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz",
+ "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-class-properties": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz",
+ "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-class-static-block": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz",
+ "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.12.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-classes": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz",
+ "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-replace-supers": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-computed-properties": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.29.7.tgz",
+ "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/template": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-destructuring": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz",
+ "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-dotall-regex": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.29.7.tgz",
+ "integrity": "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-duplicate-keys": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.29.7.tgz",
+ "integrity": "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.7.tgz",
+ "integrity": "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-dynamic-import": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.29.7.tgz",
+ "integrity": "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-explicit-resource-management": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.29.7.tgz",
+ "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/plugin-transform-destructuring": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-exponentiation-operator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.29.7.tgz",
+ "integrity": "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-export-namespace-from": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz",
+ "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-for-of": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz",
+ "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-function-name": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.29.7.tgz",
+ "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-json-strings": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.29.7.tgz",
+ "integrity": "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-literals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.29.7.tgz",
+ "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-logical-assignment-operators": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz",
+ "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-member-expression-literals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.29.7.tgz",
+ "integrity": "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-amd": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.29.7.tgz",
+ "integrity": "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-commonjs": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz",
+ "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-systemjs": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.8.tgz",
+ "integrity": "sha512-6iSnEK0zlkLKU4heofK/AdmRD4e2SHVpJMtrwnTCzhnaM98ria4rTrOXBBi45BTTYnJtO8txnPsX4fChYXkmeA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.8"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-modules-umd": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.29.7.tgz",
+ "integrity": "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-named-capturing-groups-regex": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz",
+ "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-new-target": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.29.7.tgz",
+ "integrity": "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-nullish-coalescing-operator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz",
+ "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-numeric-separator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.29.7.tgz",
+ "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-object-rest-spread": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz",
+ "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/plugin-transform-destructuring": "^7.29.7",
+ "@babel/plugin-transform-parameters": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-object-super": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.29.7.tgz",
+ "integrity": "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-replace-supers": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-optional-catch-binding": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz",
+ "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-optional-chaining": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz",
+ "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-parameters": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz",
+ "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-private-methods": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz",
+ "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-class-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-private-property-in-object": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz",
+ "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-annotate-as-pure": "^7.29.7",
+ "@babel/helper-create-class-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-property-literals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.29.7.tgz",
+ "integrity": "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/compat-data": "^7.29.7",
- "@babel/helper-validator-option": "^7.29.7",
- "browserslist": "^4.24.0",
- "lru-cache": "^5.1.1",
- "semver": "^6.3.1"
+ "@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/helper-globals": {
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
- "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
"engines": {
"node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/helper-module-imports": {
- "version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
- "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "node_modules/@babel/plugin-transform-regenerator": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.8.tgz",
+ "integrity": "sha512-0UpIXPtdDtMXfnV2OJAVMLpj3H/92vmkA6lpSRakmycJvj3VUy6Xs1dM8tXRugupykr5WB+LpiVl0J8LMVg2mg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/traverse": "^7.29.7",
- "@babel/types": "^7.29.7"
+ "@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/helper-module-transforms": {
+ "node_modules/@babel/plugin-transform-regexp-modifiers": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
- "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.29.7.tgz",
+ "integrity": "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/helper-module-imports": "^7.29.7",
- "@babel/helper-validator-identifier": "^7.29.7",
- "@babel/traverse": "^7.29.7"
+ "@babel/helper-create-regexp-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -211,83 +1355,144 @@
"@babel/core": "^7.0.0"
}
},
- "node_modules/@babel/helper-plugin-utils": {
+ "node_modules/@babel/plugin-transform-reserved-words": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
- "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.29.7.tgz",
+ "integrity": "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
"engines": {
"node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/helper-string-parser": {
+ "node_modules/@babel/plugin-transform-shorthand-properties": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
- "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.29.7.tgz",
+ "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-spread": {
+ "version": "7.29.8",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.29.8.tgz",
+ "integrity": "sha512-4S9ksMGVWUshvgK0mKfvZky7leuG5/uoFVwMpAomJ8bMoDJiNHRVmc1EglwW/CmGVSqqWpEbXm9FmbRit22qoA==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7"
+ },
"engines": {
"node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/helper-validator-identifier": {
+ "node_modules/@babel/plugin-transform-sticky-regex": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
- "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.29.7.tgz",
+ "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
"engines": {
"node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/helper-validator-option": {
+ "node_modules/@babel/plugin-transform-template-literals": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
- "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.29.7.tgz",
+ "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==",
"dev": true,
"license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
"engines": {
"node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/helpers": {
+ "node_modules/@babel/plugin-transform-typeof-symbol": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
- "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.29.7.tgz",
+ "integrity": "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/template": "^7.29.7",
- "@babel/types": "^7.29.7"
+ "@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/parser": {
- "version": "7.29.8",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz",
- "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==",
+ "node_modules/@babel/plugin-transform-unicode-escapes": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.29.7.tgz",
+ "integrity": "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@babel/types": "^7.29.8"
+ "@babel/helper-plugin-utils": "^7.29.7"
},
- "bin": {
- "parser": "bin/babel-parser.js"
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-unicode-property-regex": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.29.7.tgz",
+ "integrity": "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
- "node": ">=6.0.0"
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "node_modules/@babel/plugin-transform-unicode-regex": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
- "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz",
+ "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==",
"dev": true,
"license": "MIT",
"dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.29.7",
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
@@ -297,22 +1502,124 @@
"@babel/core": "^7.0.0-0"
}
},
- "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "node_modules/@babel/plugin-transform-unicode-sets-regex": {
"version": "7.29.7",
- "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
- "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.29.7.tgz",
+ "integrity": "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==",
"dev": true,
"license": "MIT",
"dependencies": {
+ "@babel/helper-create-regexp-features-plugin": "^7.29.7",
"@babel/helper-plugin-utils": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
},
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/preset-env": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.7.tgz",
+ "integrity": "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-plugin-utils": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7",
+ "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7",
+ "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7",
+ "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7",
+ "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7",
+ "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7",
+ "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2",
+ "@babel/plugin-syntax-import-assertions": "^7.29.7",
+ "@babel/plugin-syntax-import-attributes": "^7.29.7",
+ "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6",
+ "@babel/plugin-transform-arrow-functions": "^7.29.7",
+ "@babel/plugin-transform-async-generator-functions": "^7.29.7",
+ "@babel/plugin-transform-async-to-generator": "^7.29.7",
+ "@babel/plugin-transform-block-scoped-functions": "^7.29.7",
+ "@babel/plugin-transform-block-scoping": "^7.29.7",
+ "@babel/plugin-transform-class-properties": "^7.29.7",
+ "@babel/plugin-transform-class-static-block": "^7.29.7",
+ "@babel/plugin-transform-classes": "^7.29.7",
+ "@babel/plugin-transform-computed-properties": "^7.29.7",
+ "@babel/plugin-transform-destructuring": "^7.29.7",
+ "@babel/plugin-transform-dotall-regex": "^7.29.7",
+ "@babel/plugin-transform-duplicate-keys": "^7.29.7",
+ "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7",
+ "@babel/plugin-transform-dynamic-import": "^7.29.7",
+ "@babel/plugin-transform-explicit-resource-management": "^7.29.7",
+ "@babel/plugin-transform-exponentiation-operator": "^7.29.7",
+ "@babel/plugin-transform-export-namespace-from": "^7.29.7",
+ "@babel/plugin-transform-for-of": "^7.29.7",
+ "@babel/plugin-transform-function-name": "^7.29.7",
+ "@babel/plugin-transform-json-strings": "^7.29.7",
+ "@babel/plugin-transform-literals": "^7.29.7",
+ "@babel/plugin-transform-logical-assignment-operators": "^7.29.7",
+ "@babel/plugin-transform-member-expression-literals": "^7.29.7",
+ "@babel/plugin-transform-modules-amd": "^7.29.7",
+ "@babel/plugin-transform-modules-commonjs": "^7.29.7",
+ "@babel/plugin-transform-modules-systemjs": "^7.29.7",
+ "@babel/plugin-transform-modules-umd": "^7.29.7",
+ "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7",
+ "@babel/plugin-transform-new-target": "^7.29.7",
+ "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7",
+ "@babel/plugin-transform-numeric-separator": "^7.29.7",
+ "@babel/plugin-transform-object-rest-spread": "^7.29.7",
+ "@babel/plugin-transform-object-super": "^7.29.7",
+ "@babel/plugin-transform-optional-catch-binding": "^7.29.7",
+ "@babel/plugin-transform-optional-chaining": "^7.29.7",
+ "@babel/plugin-transform-parameters": "^7.29.7",
+ "@babel/plugin-transform-private-methods": "^7.29.7",
+ "@babel/plugin-transform-private-property-in-object": "^7.29.7",
+ "@babel/plugin-transform-property-literals": "^7.29.7",
+ "@babel/plugin-transform-regenerator": "^7.29.7",
+ "@babel/plugin-transform-regexp-modifiers": "^7.29.7",
+ "@babel/plugin-transform-reserved-words": "^7.29.7",
+ "@babel/plugin-transform-shorthand-properties": "^7.29.7",
+ "@babel/plugin-transform-spread": "^7.29.7",
+ "@babel/plugin-transform-sticky-regex": "^7.29.7",
+ "@babel/plugin-transform-template-literals": "^7.29.7",
+ "@babel/plugin-transform-typeof-symbol": "^7.29.7",
+ "@babel/plugin-transform-unicode-escapes": "^7.29.7",
+ "@babel/plugin-transform-unicode-property-regex": "^7.29.7",
+ "@babel/plugin-transform-unicode-regex": "^7.29.7",
+ "@babel/plugin-transform-unicode-sets-regex": "^7.29.7",
+ "@babel/preset-modules": "0.1.6-no-external-plugins",
+ "babel-plugin-polyfill-corejs2": "^0.4.15",
+ "babel-plugin-polyfill-corejs3": "^0.14.0",
+ "babel-plugin-polyfill-regenerator": "^0.6.6",
+ "core-js-compat": "^3.48.0",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
"peerDependencies": {
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/preset-modules": {
+ "version": "0.1.6-no-external-plugins",
+ "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz",
+ "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.0.0",
+ "@babel/types": "^7.4.4",
+ "esutils": "^2.0.2"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0"
+ }
+ },
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
@@ -1167,6 +2474,17 @@
"node": ">=6.0.0"
}
},
+ "node_modules/@jridgewell/source-map": {
+ "version": "0.3.11",
+ "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz",
+ "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.25"
+ }
+ },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
@@ -1194,41 +2512,219 @@
],
"dev": true,
"license": "MIT",
- "optional": true,
- "os": [
- "linux"
- ],
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^22.20 || ^24.12 || >=25"
+ }
+ },
+ "node_modules/@pkgjs/parseargs": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
+ "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
+ "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@remix-run/router": {
+ "version": "1.23.3",
+ "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
+ "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/plugin-babel": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-6.1.0.tgz",
+ "integrity": "sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.18.6",
+ "@rollup/pluginutils": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0",
+ "@types/babel__core": "^7.1.9",
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/babel__core": {
+ "optional": true
+ },
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/plugin-node-resolve": {
+ "version": "16.0.3",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz",
+ "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.0.1",
+ "@types/resolve": "1.20.2",
+ "deepmerge": "^4.2.2",
+ "is-module": "^1.0.0",
+ "resolve": "^1.22.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^2.78.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@rollup/plugin-node-resolve/node_modules/resolve": {
+ "version": "1.22.12",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+ "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/@rollup/plugin-replace": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz",
+ "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rollup/pluginutils": "^5.0.1",
+ "magic-string": "^0.30.3"
+ },
"engines": {
- "node": "^22.20 || ^24.12 || >=25"
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
}
},
- "node_modules/@pkgjs/parseargs": {
- "version": "0.11.0",
- "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
- "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
+ "node_modules/@rollup/plugin-terser": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@rollup/plugin-terser/-/plugin-terser-1.0.0.tgz",
+ "integrity": "sha512-FnCxhTBx6bMOYQrar6C8h3scPt8/JwIzw3+AJ2K++6guogH5fYaIFia+zZuhqv0eo1RN7W1Pz630SyvLbDjhtQ==",
"dev": true,
"license": "MIT",
- "optional": true,
+ "dependencies": {
+ "serialize-javascript": "^7.0.3",
+ "smob": "^1.0.0",
+ "terser": "^5.17.4"
+ },
"engines": {
- "node": ">=14"
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
}
},
- "node_modules/@remix-run/router": {
- "version": "1.23.3",
- "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
- "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
+ "node_modules/@rollup/pluginutils": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
+ "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==",
+ "dev": true,
"license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0",
+ "estree-walker": "^2.0.2",
+ "picomatch": "^4.0.2"
+ },
"engines": {
"node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
+ },
+ "peerDependenciesMeta": {
+ "rollup": {
+ "optional": true
+ }
}
},
- "node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-beta.27",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
- "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "node_modules/@rollup/pluginutils/node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
+ "node_modules/@rollup/pluginutils/node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz",
@@ -1668,6 +3164,22 @@
"@testing-library/dom": ">=7.21.4"
}
},
+ "node_modules/@trickfilm400/rollup-plugin-off-main-thread": {
+ "version": "3.0.0-pre1",
+ "resolved": "https://registry.npmjs.org/@trickfilm400/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-3.0.0-pre1.tgz",
+ "integrity": "sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "ejs": "^3.1.10",
+ "json5": "^2.2.3",
+ "magic-string": "^0.30.21",
+ "string.prototype.matchall": "^4.0.12"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
@@ -1734,6 +3246,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/node": {
+ "version": "20.19.43",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
+ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1762,6 +3284,20 @@
"@types/react": "^18.0.0"
}
},
+ "node_modules/@types/resolve": {
+ "version": "1.20.2",
+ "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
+ "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.66.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz",
@@ -2479,6 +4015,13 @@
"node": ">=12"
}
},
+ "node_modules/async": {
+ "version": "3.2.6",
+ "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+ "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/async-function": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz",
@@ -2496,6 +4039,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/at-least-node": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
+ "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">= 4.0.0"
+ }
+ },
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
@@ -2512,6 +4065,48 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/babel-plugin-polyfill-corejs2": {
+ "version": "0.4.17",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz",
+ "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-define-polyfill-provider": "^0.6.8",
+ "semver": "^6.3.1"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-corejs3": {
+ "version": "0.14.2",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz",
+ "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": "^0.6.8",
+ "core-js-compat": "^3.48.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
+ "node_modules/babel-plugin-polyfill-regenerator": {
+ "version": "0.6.8",
+ "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz",
+ "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-define-polyfill-provider": "^0.6.8"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0"
+ }
+ },
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2603,6 +4198,13 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
+ "node_modules/buffer-from": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cac": {
"version": "6.7.14",
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
@@ -2809,6 +4411,23 @@
"node": ">= 0.8"
}
},
+ "node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/common-tags": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.2.tgz",
+ "integrity": "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2823,6 +4442,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/core-js-compat": {
+ "version": "3.50.0",
+ "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz",
+ "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.7"
+ },
+ "engines": {
+ "node": ">=6.4.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2838,6 +4474,16 @@
"node": ">= 8"
}
},
+ "node_modules/crypto-random-string": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
+ "integrity": "sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@@ -2983,6 +4629,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -3081,6 +4737,22 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/ejs": {
+ "version": "3.1.10",
+ "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
+ "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "jake": "^10.8.5"
+ },
+ "bin": {
+ "ejs": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/electron-to-chromium": {
"version": "1.5.401",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.401.tgz",
@@ -3609,6 +5281,19 @@
"node": ">=0.10.0"
}
},
+ "node_modules/eta": {
+ "version": "4.6.0",
+ "resolved": "https://registry.npmjs.org/eta/-/eta-4.6.0.tgz",
+ "integrity": "sha512-lW6is4T1NFOYnmqGZIfvixqj7A7sSvScF+DN8EK6K58xI5MZ5UvYe0GjopxOXQtZvUn4eDdVuZ8XSoYWTMEKwA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "funding": {
+ "url": "https://github.com/bgub/eta?sponsor=1"
+ }
+ },
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
@@ -3640,6 +5325,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-uri": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
+ "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -3653,6 +5355,39 @@
"node": ">=16.0.0"
}
},
+ "node_modules/filelist": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
+ "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "minimatch": "^5.0.1"
+ }
+ },
+ "node_modules/filelist/node_modules/brace-expansion": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
+ "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/filelist/node_modules/minimatch": {
+ "version": "5.1.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -3754,6 +5489,22 @@
"node": ">= 6"
}
},
+ "node_modules/fs-extra": {
+ "version": "9.1.0",
+ "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
+ "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "at-least-node": "^1.0.0",
+ "graceful-fs": "^4.2.0",
+ "jsonfile": "^6.0.1",
+ "universalify": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3858,6 +5609,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/get-own-enumerable-property-symbols": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz",
+ "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
@@ -3994,6 +5752,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/has-bigints": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
@@ -4149,6 +5914,13 @@
"node": ">=0.10.0"
}
},
+ "node_modules/idb": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
+ "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
+ "dev": true,
+ "license": "ISC"
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4464,6 +6236,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-module": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
+ "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/is-negative-zero": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
@@ -4504,6 +6283,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-obj": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-obj/-/is-obj-1.0.1.tgz",
+ "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
@@ -4530,6 +6319,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-regexp": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz",
+ "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/is-set": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz",
@@ -4559,6 +6358,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-stream": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+ "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/is-string": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz",
@@ -4758,6 +6570,24 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
+ "node_modules/jake": {
+ "version": "10.9.4",
+ "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
+ "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "async": "^3.2.6",
+ "filelist": "^1.0.4",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "jake": "bin/cli.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -4875,6 +6705,29 @@
"node": ">=6"
}
},
+ "node_modules/jsonfile": {
+ "version": "6.2.1",
+ "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
+ "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "universalify": "^2.0.0"
+ },
+ "optionalDependencies": {
+ "graceful-fs": "^4.1.6"
+ }
+ },
+ "node_modules/jsonpointer": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz",
+ "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/jsx-ast-utils": {
"version": "3.3.5",
"resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz",
@@ -4901,6 +6754,16 @@
"json-buffer": "3.0.1"
}
},
+ "node_modules/leven": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
+ "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -4931,6 +6794,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash.debounce": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
+ "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -5471,6 +7341,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
+ "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.62.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.62.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
+ "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -5536,6 +7453,19 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
+ "node_modules/pretty-bytes": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
+ "integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -5717,6 +7647,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/regenerate": {
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
+ "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/regenerate-unicode-properties": {
+ "version": "10.2.2",
+ "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz",
+ "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "regenerate": "^1.4.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
@@ -5738,6 +7688,54 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/regexpu-core": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz",
+ "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "regenerate": "^1.4.2",
+ "regenerate-unicode-properties": "^10.2.2",
+ "regjsgen": "^0.8.0",
+ "regjsparser": "^0.13.0",
+ "unicode-match-property-ecmascript": "^2.0.0",
+ "unicode-match-property-value-ecmascript": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/regjsgen": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz",
+ "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/regjsparser": {
+ "version": "0.13.2",
+ "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.2.tgz",
+ "integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "jsesc": "~3.1.0"
+ },
+ "bin": {
+ "regjsparser": "bin/parser"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/resolve": {
"version": "2.0.0-next.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
@@ -5937,6 +7935,16 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/serialize-javascript": {
+ "version": "7.0.7",
+ "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.7.tgz",
+ "integrity": "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -6105,6 +8113,26 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/smob": {
+ "version": "1.6.2",
+ "resolved": "https://registry.npmjs.org/smob/-/smob-1.6.2.tgz",
+ "integrity": "sha512-RQsvleCbF8cVHEv+xuDGaA4pOizFqJ0GgjtMSRo6oP8pnN7WsigHgVGey6aILRBKv4W2YOMHLqbKdnB6hpB9fw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/source-map": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz",
+ "integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -6115,6 +8143,27 @@
"node": ">=0.10.0"
}
},
+ "node_modules/source-map-support": {
+ "version": "0.5.21",
+ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "buffer-from": "^1.0.0",
+ "source-map": "^0.6.0"
+ }
+ },
+ "node_modules/source-map-support/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/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -6296,6 +8345,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/stringify-object": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/stringify-object/-/stringify-object-3.3.0.tgz",
+ "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "get-own-enumerable-property-symbols": "^3.0.0",
+ "is-obj": "^1.0.1",
+ "is-regexp": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
@@ -6339,6 +8403,16 @@
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
+ "node_modules/strip-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz",
+ "integrity": "sha512-ZprKx+bBLXv067WTCALv8SSz5l2+XhpYCsVtSqlMnkAXMWDq+/ekVbl1ghqP9rUHTzv6sm/DwCOiYutU/yp1fw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -6398,6 +8472,54 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/temp-dir": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz",
+ "integrity": "sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tempy": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tempy/-/tempy-0.6.0.tgz",
+ "integrity": "sha512-G13vtMYPT/J8A4X2SjdtBTphZlrp1gKv6hZiOjw14RCWg6GbHuQBGtjlx75xLbYV/wEc0D7G5K4rxKP/cXk8Bw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-stream": "^2.0.0",
+ "temp-dir": "^2.0.0",
+ "type-fest": "^0.16.0",
+ "unique-string": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/terser": {
+ "version": "5.49.2",
+ "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.2.tgz",
+ "integrity": "sha512-rGbJiKeQ4WDe3EXlDAIaQcwftVfv2Q8o1awFNfvXolJYKkb1AuZY1RTOmqx4LJXZENbWZA7eIsYGHuEzHsi1nQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@jridgewell/source-map": "^0.3.3",
+ "acorn": "^8.15.0",
+ "commander": "^2.20.0",
+ "source-map-support": "~0.5.20"
+ },
+ "bin": {
+ "terser": "bin/terser"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/test-exclude": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz",
@@ -6629,6 +8751,19 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/type-fest": {
+ "version": "0.16.0",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.16.0.tgz",
+ "integrity": "sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==",
+ "dev": true,
+ "license": "(MIT OR CC0-1.0)",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/typed-array-buffer": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz",
@@ -6721,47 +8856,132 @@
"node": ">=14.17"
}
},
- "node_modules/typescript-eslint": {
- "version": "8.66.0",
- "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz",
- "integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==",
+ "node_modules/typescript-eslint": {
+ "version": "8.66.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz",
+ "integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.66.0",
+ "@typescript-eslint/parser": "8.66.0",
+ "@typescript-eslint/typescript-estree": "8.66.0",
+ "@typescript-eslint/utils": "8.66.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/unbox-primitive": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
+ "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.3",
+ "has-bigints": "^1.0.2",
+ "has-symbols": "^1.1.0",
+ "which-boxed-primitive": "^1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/unicode-canonical-property-names-ecmascript": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz",
+ "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-match-property-ecmascript": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz",
+ "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "unicode-canonical-property-names-ecmascript": "^2.0.0",
+ "unicode-property-aliases-ecmascript": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-match-property-value-ecmascript": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz",
+ "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unicode-property-aliases-ecmascript": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz",
+ "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/unique-string": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-2.0.0.tgz",
+ "integrity": "sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@typescript-eslint/eslint-plugin": "8.66.0",
- "@typescript-eslint/parser": "8.66.0",
- "@typescript-eslint/typescript-estree": "8.66.0",
- "@typescript-eslint/utils": "8.66.0"
+ "crypto-random-string": "^2.0.0"
},
"engines": {
- "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/typescript-eslint"
- },
- "peerDependencies": {
- "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
- "typescript": ">=4.8.4 <6.1.0"
+ "node": ">=8"
}
},
- "node_modules/unbox-primitive": {
- "version": "1.1.0",
- "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",
- "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==",
+ "node_modules/universalify": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
+ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "call-bound": "^1.0.3",
- "has-bigints": "^1.0.2",
- "has-symbols": "^1.1.0",
- "which-boxed-primitive": "^1.1.1"
- },
"engines": {
- "node": ">= 0.4"
- },
- "funding": {
- "url": "https://github.com/sponsors/ljharb"
+ "node": ">= 10.0.0"
+ }
+ },
+ "node_modules/upath": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz",
+ "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4",
+ "yarn": "*"
}
},
"node_modules/update-browserslist-db": {
@@ -6888,6 +9108,37 @@
"url": "https://opencollective.com/vitest"
}
},
+ "node_modules/vite-plugin-pwa": {
+ "version": "0.20.5",
+ "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.20.5.tgz",
+ "integrity": "sha512-aweuI/6G6n4C5Inn0vwHumElU/UEpNuO+9iZzwPZGTCH87TeZ6YFMrEY6ZUBQdIHHlhTsbMDryFARcSuOdsz9Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.6",
+ "pretty-bytes": "^6.1.1",
+ "tinyglobby": "^0.2.0",
+ "workbox-build": "^7.1.0",
+ "workbox-window": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ },
+ "peerDependencies": {
+ "@vite-pwa/assets-generator": "^0.2.6",
+ "vite": "^3.1.0 || ^4.0.0 || ^5.0.0",
+ "workbox-build": "^7.1.0",
+ "workbox-window": "^7.1.0"
+ },
+ "peerDependenciesMeta": {
+ "@vite-pwa/assets-generator": {
+ "optional": true
+ }
+ }
+ },
"node_modules/vitest": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz",
@@ -7147,6 +9398,384 @@
"node": ">=0.10.0"
}
},
+ "node_modules/workbox-background-sync": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.4.1.tgz",
+ "integrity": "sha512-HhT7KE8tOWDm02wRNshXUnUPofMlhenF2DBdUnDPOubhizzPeItkYTmAB6td1Z2cjYPa98vzEiPLEuzn5hN66g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "idb": "^7.0.1",
+ "workbox-core": "7.4.1"
+ }
+ },
+ "node_modules/workbox-broadcast-update": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-broadcast-update/-/workbox-broadcast-update-7.4.1.tgz",
+ "integrity": "sha512-uAlgslKLvbQY+suirIdnBCSYrcgBhjp81Nj4l1lj/Jmj0MJO2CJERnCJjT0GFVwmReV0N+zs78K6gqd5gr9/+A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.1"
+ }
+ },
+ "node_modules/workbox-build": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-build/-/workbox-build-7.4.1.tgz",
+ "integrity": "sha512-SDhxIvEAde9Gy/5w4Yo1Jh/M49Z0qE3q0oteyE8zGq0DScxFqVBcCtIXFuLtmtxRQZCMbf0prco4VyEu3KBQuw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@apideck/better-ajv-errors": "^0.3.1",
+ "@babel/core": "^7.24.4",
+ "@babel/preset-env": "^7.11.0",
+ "@babel/runtime": "^7.11.2",
+ "@rollup/plugin-babel": "^6.1.0",
+ "@rollup/plugin-node-resolve": "^16.0.3",
+ "@rollup/plugin-replace": "^6.0.3",
+ "@rollup/plugin-terser": "^1.0.0",
+ "@trickfilm400/rollup-plugin-off-main-thread": "^3.0.0-pre1",
+ "ajv": "^8.6.0",
+ "common-tags": "^1.8.0",
+ "eta": "^4.5.1",
+ "fast-json-stable-stringify": "^2.1.0",
+ "fs-extra": "^9.0.1",
+ "glob": "^11.0.1",
+ "pretty-bytes": "^5.3.0",
+ "rollup": "^4.53.3",
+ "source-map": "^0.8.0-beta.0",
+ "stringify-object": "^3.3.0",
+ "strip-comments": "^2.0.1",
+ "tempy": "^0.6.0",
+ "upath": "^1.2.0",
+ "workbox-background-sync": "7.4.1",
+ "workbox-broadcast-update": "7.4.1",
+ "workbox-cacheable-response": "7.4.1",
+ "workbox-core": "7.4.1",
+ "workbox-expiration": "7.4.1",
+ "workbox-google-analytics": "7.4.1",
+ "workbox-navigation-preload": "7.4.1",
+ "workbox-precaching": "7.4.1",
+ "workbox-range-requests": "7.4.1",
+ "workbox-recipes": "7.4.1",
+ "workbox-routing": "7.4.1",
+ "workbox-strategies": "7.4.1",
+ "workbox-streams": "7.4.1",
+ "workbox-sw": "7.4.1",
+ "workbox-window": "7.4.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/workbox-build/node_modules/@apideck/better-ajv-errors": {
+ "version": "0.3.7",
+ "resolved": "https://registry.npmjs.org/@apideck/better-ajv-errors/-/better-ajv-errors-0.3.7.tgz",
+ "integrity": "sha512-TajUJwGWbDwkCx/CZi7tRE8PVB7simCvKJfHUsSdvps+aTM/PDPP4gkLmKnc+x3CE//y9i/nj74GqdL/hwk7Iw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "jsonpointer": "^5.0.1",
+ "leven": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "ajv": ">=8"
+ }
+ },
+ "node_modules/workbox-build/node_modules/@isaacs/cliui": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
+ "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/workbox-build/node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/workbox-build/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/workbox-build/node_modules/brace-expansion": {
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/workbox-build/node_modules/glob": {
+ "version": "11.1.0",
+ "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
+ "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
+ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "foreground-child": "^3.3.1",
+ "jackspeak": "^4.1.1",
+ "minimatch": "^10.1.1",
+ "minipass": "^7.1.2",
+ "package-json-from-dist": "^1.0.0",
+ "path-scurry": "^2.0.0"
+ },
+ "bin": {
+ "glob": "dist/esm/bin.mjs"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/workbox-build/node_modules/jackspeak": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
+ "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "@isaacs/cliui": "^9.0.0"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/workbox-build/node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/workbox-build/node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/workbox-build/node_modules/minimatch": {
+ "version": "10.2.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+ "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.8"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/workbox-build/node_modules/path-scurry": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
+ "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "lru-cache": "^11.0.0",
+ "minipass": "^7.1.2"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/workbox-build/node_modules/pretty-bytes": {
+ "version": "5.6.0",
+ "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
+ "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/workbox-cacheable-response": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.4.1.tgz",
+ "integrity": "sha512-8xaFoJdDc2OjrlbbL3gEeBO1WKcMwRqwLRupgqahYXu75yXajPLuwrbXMrIGZuWYXrQwk0xDjOxZ/ujCy/oJYw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.1"
+ }
+ },
+ "node_modules/workbox-core": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-core/-/workbox-core-7.4.1.tgz",
+ "integrity": "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/workbox-expiration": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-expiration/-/workbox-expiration-7.4.1.tgz",
+ "integrity": "sha512-lRKUF7b+OGbeXkQk1s6MHXOa3d7Xxf7Of31W6c6hCfipfIyrtdWZ89stq21AHZMaoG7VNFoHply4Ox+rU31TWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "idb": "^7.0.1",
+ "workbox-core": "7.4.1"
+ }
+ },
+ "node_modules/workbox-google-analytics": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-google-analytics/-/workbox-google-analytics-7.4.1.tgz",
+ "integrity": "sha512-Mks1JwLEt++ZAkF6sS1OpSh9RtAMIsiDgRpK+codiHGIPXeaUOgi4cPc3GFadUl8V5QPeypEk8Oxgl3HlwVzHw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-background-sync": "7.4.1",
+ "workbox-core": "7.4.1",
+ "workbox-routing": "7.4.1",
+ "workbox-strategies": "7.4.1"
+ }
+ },
+ "node_modules/workbox-navigation-preload": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-navigation-preload/-/workbox-navigation-preload-7.4.1.tgz",
+ "integrity": "sha512-C4KVsjPcYKJOhr631AxR9XoG2rLF3QiTk5aMv36MXOjtWvm8axwNFAtKUPGsWUwLXXAMgYM1En7fsvndaXeXRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.1"
+ }
+ },
+ "node_modules/workbox-precaching": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-precaching/-/workbox-precaching-7.4.1.tgz",
+ "integrity": "sha512-cdr/9qByww7yzEp7zg/qI4ukUrrNjQLgN+ONQRpjy/VqGQXwkgHwr00KksGJK8v0VifwDXBb8a4cWNZH71jn3Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.1",
+ "workbox-routing": "7.4.1",
+ "workbox-strategies": "7.4.1"
+ }
+ },
+ "node_modules/workbox-range-requests": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-range-requests/-/workbox-range-requests-7.4.1.tgz",
+ "integrity": "sha512-7i2oxAUE82gHdAJBCAQ04JzNOdRPqzuOzGfoUyJpFSmeqBNYGPrAH8GPoPjUQTfp+NycwrD2H68VtuF8qxv0vQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.1"
+ }
+ },
+ "node_modules/workbox-recipes": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-recipes/-/workbox-recipes-7.4.1.tgz",
+ "integrity": "sha512-gnbVfmV4/TtmQaM4x9AtuXhcdstJsep3XMVeztOrQVPT+R6+6DeBjGTCQ7fFCXm+4GEHUA5VEBTyi5+4gWGeog==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-cacheable-response": "7.4.1",
+ "workbox-core": "7.4.1",
+ "workbox-expiration": "7.4.1",
+ "workbox-precaching": "7.4.1",
+ "workbox-routing": "7.4.1",
+ "workbox-strategies": "7.4.1"
+ }
+ },
+ "node_modules/workbox-routing": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-routing/-/workbox-routing-7.4.1.tgz",
+ "integrity": "sha512-yubJGErZOusuidAenaL5ypfhQOa7urxP/f8E0ws7FPb4039RiWXUWBAyUkmUoOL/BcQGen3h0J8872d51IYxtA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.1"
+ }
+ },
+ "node_modules/workbox-strategies": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-strategies/-/workbox-strategies-7.4.1.tgz",
+ "integrity": "sha512-GZxpaw9NbmOelj7667uZ2kpk5BFpOGbO4X0qjwh5ls8XQ8C+Lha5LQchTiUzsTFSS+NlUpftYAyOVXvQUrcqOQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.1"
+ }
+ },
+ "node_modules/workbox-streams": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-streams/-/workbox-streams-7.4.1.tgz",
+ "integrity": "sha512-HWWtraKUbJknd9kgqGcpQ3G114HOPYvqs8HaJMDs2ebLNAimDkVDaWfAXE6Ybl+m8U6KsCE6pWyLYuigWmnAXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "workbox-core": "7.4.1",
+ "workbox-routing": "7.4.1"
+ }
+ },
+ "node_modules/workbox-sw": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-sw/-/workbox-sw-7.4.1.tgz",
+ "integrity": "sha512-fez5f2DUlDJWTFYkCWQpY10N8gtztd849NswCbVFk0QlcSM4HT5A8x4g4ii650yem4I8tHY0R7JZahwp3ltIPw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/workbox-window": {
+ "version": "7.4.1",
+ "resolved": "https://registry.npmjs.org/workbox-window/-/workbox-window-7.4.1.tgz",
+ "integrity": "sha512-notZDH2u8VXaqyuD7xaqIfEFi6SRM4SUSd7ewe9PDsVqADuepxX2ZMY3uvuZGxzY5ZOsGC/vD3A/3smFtJt4/A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/trusted-types": "^2.0.2",
+ "workbox-core": "7.4.1"
+ }
+ },
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
diff --git a/package.json b/package.json
index ae8aeb42..24712dd1 100644
--- a/package.json
+++ b/package.json
@@ -1,57 +1,58 @@
{
- "name": "angular-hnpwa",
- "version": "0.0.0",
- "scripts": {
- "ng": "ng",
- "start": "ng serve",
- "build": "ng build",
- "test": "ng test",
- "lint": "ng lint",
- "e2e": "ng e2e"
- },
- "private": true,
- "dependencies": {
- "@angular/animations": "~9.0.1",
- "@angular/common": "~9.0.1",
- "@angular/compiler": "~9.0.1",
- "@angular/core": "~9.0.1",
- "@angular/forms": "~9.0.1",
- "@angular/platform-browser": "~9.0.1",
- "@angular/platform-browser-dynamic": "~9.0.1",
- "@angular/router": "~9.0.1",
- "@angular/service-worker": "~9.0.1",
- "node-fetch": "^2.6.0",
- "rxjs": "~6.5.4",
- "rxjs-compat": "^6.5.2",
- "tslib": "^1.10.0",
- "unfetch": "^4.1.0",
- "zone.js": "~0.10.2"
- },
- "devDependencies": {
- "@angular-devkit/build-angular": "~0.900.2",
- "@angular/cli": "~9.0.2",
- "@angular/compiler-cli": "~9.0.1",
- "@angular/language-service": "~9.0.1",
- "@types/jasmine": "~3.3.8",
- "@types/jasminewd2": "~2.0.3",
- "@types/node": "^12.11.1",
- "codelyzer": "^5.1.2",
- "jasmine-core": "~3.4.0",
- "jasmine-spec-reporter": "~4.2.1",
- "karma": "~4.1.0",
- "karma-chrome-launcher": "~2.2.0",
- "karma-coverage-istanbul-reporter": "~2.0.1",
- "karma-jasmine": "~2.0.1",
- "karma-jasmine-html-reporter": "^1.4.0",
- "protractor": "~5.4.0",
- "ts-node": "~7.0.0",
- "tslint": "~5.15.0",
- "typescript": "~3.7.5"
- },
- "prettier": {
- "trailingComma": "es5",
- "tabWidth": 4,
- "singleQuote": true,
- "printWidth": 120
- }
+ "name": "angular2-hn",
+ "description": "A Hacker News progressive web app built with React, TypeScript and Vite",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "start": "vite",
+ "build": "tsc --noEmit && vite build",
+ "preview": "vite preview",
+ "lint": "eslint .",
+ "format": "prettier --write \"src/**/*.{ts,tsx,scss}\"",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:coverage": "vitest run --coverage",
+ "test:e2e": "playwright test",
+ "test:e2e:ui": "playwright test --ui"
+ },
+ "dependencies": {
+ "react": "^18.3.1",
+ "react-dom": "^18.3.1",
+ "react-router-dom": "^6.26.2"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.11.1",
+ "@playwright/test": "^1.62.1",
+ "@testing-library/dom": "^10.4.0",
+ "@testing-library/jest-dom": "^6.5.0",
+ "@testing-library/react": "^16.0.1",
+ "@testing-library/user-event": "^14.5.2",
+ "@types/node": "^20.19.43",
+ "@types/react": "^18.3.10",
+ "@types/react-dom": "^18.3.0",
+ "@vitejs/plugin-react": "^4.3.1",
+ "@vitest/coverage-v8": "^2.1.1",
+ "eslint": "^9.11.1",
+ "eslint-config-prettier": "^9.1.0",
+ "eslint-plugin-react": "^7.37.0",
+ "eslint-plugin-react-hooks": "^5.0.0",
+ "eslint-plugin-react-refresh": "^0.4.12",
+ "globals": "^15.9.0",
+ "jsdom": "^25.0.1",
+ "prettier": "^3.3.3",
+ "sass": "~1.77.8",
+ "typescript": "^5.5.4",
+ "typescript-eslint": "^8.7.0",
+ "vite": "^5.4.8",
+ "vite-plugin-pwa": "^0.20.5",
+ "vitest": "^2.1.1"
+ },
+ "prettier": {
+ "trailingComma": "es5",
+ "tabWidth": 4,
+ "singleQuote": true,
+ "printWidth": 120
+ }
}
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 00000000..78579bc6
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,22 @@
+import { defineConfig, devices } from '@playwright/test';
+
+const PORT = 4173;
+
+export default defineConfig({
+ testDir: './e2e',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 1 : 0,
+ reporter: process.env.CI ? [['html', { open: 'never' }], ['list']] : 'list',
+ use: {
+ baseURL: `http://localhost:${PORT}`,
+ trace: 'on-first-retry',
+ },
+ projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
+ webServer: {
+ command: `npm run build && npm run preview -- --port ${PORT} --strictPort`,
+ url: `http://localhost:${PORT}`,
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000,
+ },
+});
diff --git a/src/assets/icons/android-chrome-144x144.png b/public/assets/icons/android-chrome-144x144.png
similarity index 100%
rename from src/assets/icons/android-chrome-144x144.png
rename to public/assets/icons/android-chrome-144x144.png
diff --git a/src/assets/icons/android-chrome-192x192.png b/public/assets/icons/android-chrome-192x192.png
similarity index 100%
rename from src/assets/icons/android-chrome-192x192.png
rename to public/assets/icons/android-chrome-192x192.png
diff --git a/src/assets/icons/android-chrome-256x256.png b/public/assets/icons/android-chrome-256x256.png
similarity index 100%
rename from src/assets/icons/android-chrome-256x256.png
rename to public/assets/icons/android-chrome-256x256.png
diff --git a/src/assets/icons/android-chrome-512x512.png b/public/assets/icons/android-chrome-512x512.png
similarity index 100%
rename from src/assets/icons/android-chrome-512x512.png
rename to public/assets/icons/android-chrome-512x512.png
diff --git a/src/assets/icons/apple-touch-icon-120x120.png b/public/assets/icons/apple-touch-icon-120x120.png
similarity index 100%
rename from src/assets/icons/apple-touch-icon-120x120.png
rename to public/assets/icons/apple-touch-icon-120x120.png
diff --git a/src/assets/icons/apple-touch-icon-152x152.png b/public/assets/icons/apple-touch-icon-152x152.png
similarity index 100%
rename from src/assets/icons/apple-touch-icon-152x152.png
rename to public/assets/icons/apple-touch-icon-152x152.png
diff --git a/src/assets/icons/apple-touch-icon-180x180.png b/public/assets/icons/apple-touch-icon-180x180.png
similarity index 100%
rename from src/assets/icons/apple-touch-icon-180x180.png
rename to public/assets/icons/apple-touch-icon-180x180.png
diff --git a/src/assets/icons/apple-touch-icon-60x60.png b/public/assets/icons/apple-touch-icon-60x60.png
similarity index 100%
rename from src/assets/icons/apple-touch-icon-60x60.png
rename to public/assets/icons/apple-touch-icon-60x60.png
diff --git a/src/assets/icons/apple-touch-icon-76x76.png b/public/assets/icons/apple-touch-icon-76x76.png
similarity index 100%
rename from src/assets/icons/apple-touch-icon-76x76.png
rename to public/assets/icons/apple-touch-icon-76x76.png
diff --git a/src/assets/icons/apple-touch-icon.png b/public/assets/icons/apple-touch-icon.png
similarity index 100%
rename from src/assets/icons/apple-touch-icon.png
rename to public/assets/icons/apple-touch-icon.png
diff --git a/src/assets/icons/browserconfig.xml b/public/assets/icons/browserconfig.xml
similarity index 100%
rename from src/assets/icons/browserconfig.xml
rename to public/assets/icons/browserconfig.xml
diff --git a/src/assets/icons/favicon-16x16.png b/public/assets/icons/favicon-16x16.png
similarity index 100%
rename from src/assets/icons/favicon-16x16.png
rename to public/assets/icons/favicon-16x16.png
diff --git a/src/assets/icons/favicon-32x32.png b/public/assets/icons/favicon-32x32.png
similarity index 100%
rename from src/assets/icons/favicon-32x32.png
rename to public/assets/icons/favicon-32x32.png
diff --git a/src/assets/icons/mstile-150x150.png b/public/assets/icons/mstile-150x150.png
similarity index 100%
rename from src/assets/icons/mstile-150x150.png
rename to public/assets/icons/mstile-150x150.png
diff --git a/src/assets/icons/safari-pinned-tab.svg b/public/assets/icons/safari-pinned-tab.svg
similarity index 100%
rename from src/assets/icons/safari-pinned-tab.svg
rename to public/assets/icons/safari-pinned-tab.svg
diff --git a/src/assets/images/cog.svg b/public/assets/images/cog.svg
similarity index 100%
rename from src/assets/images/cog.svg
rename to public/assets/images/cog.svg
diff --git a/src/assets/images/logo-header.png b/public/assets/images/logo-header.png
similarity index 100%
rename from src/assets/images/logo-header.png
rename to public/assets/images/logo-header.png
diff --git a/src/assets/images/logo.svg b/public/assets/images/logo.svg
similarity index 100%
rename from src/assets/images/logo.svg
rename to public/assets/images/logo.svg
diff --git a/src/favicon.ico b/public/favicon.ico
similarity index 100%
rename from src/favicon.ico
rename to public/favicon.ico
diff --git a/web/src/App.scss b/src/App.scss
similarity index 100%
rename from web/src/App.scss
rename to src/App.scss
diff --git a/web/src/App.test.tsx b/src/App.test.tsx
similarity index 100%
rename from web/src/App.test.tsx
rename to src/App.test.tsx
diff --git a/web/src/App.tsx b/src/App.tsx
similarity index 100%
rename from web/src/App.tsx
rename to src/App.tsx
diff --git a/web/src/api/hackerNews.test.ts b/src/api/hackerNews.test.ts
similarity index 100%
rename from web/src/api/hackerNews.test.ts
rename to src/api/hackerNews.test.ts
diff --git a/web/src/api/hackerNews.ts b/src/api/hackerNews.ts
similarity index 100%
rename from web/src/api/hackerNews.ts
rename to src/api/hackerNews.ts
diff --git a/src/app/app.component.html b/src/app/app.component.html
deleted file mode 100644
index e60bb461..00000000
--- a/src/app/app.component.html
+++ /dev/null
@@ -1,8 +0,0 @@
-
diff --git a/src/app/app.component.scss b/src/app/app.component.scss
deleted file mode 100644
index c0b93891..00000000
--- a/src/app/app.component.scss
+++ /dev/null
@@ -1,24 +0,0 @@
-@import "./shared/scss/media";
-@import "./shared/scss/theme_variables";
-
-.body-cover {
- width: 100%;
- z-index: 0;
- position: fixed;
- height: 100%;
-}
-
-.wrapper {
- position: relative;
- width: 85%;
- min-height: 80px;
- margin: 0 auto;
- font-family: "HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
- font-size: 15px;
- height: 100%;
- line-height: 1.3;
-
- @media #{$mobile-only} {
- width: 100%;
- }
-}
diff --git a/src/app/app.component.ts b/src/app/app.component.ts
deleted file mode 100644
index dba13510..00000000
--- a/src/app/app.component.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { Component } from '@angular/core';
-import { Router, NavigationEnd } from '@angular/router';
-
-import { SettingsService } from './shared/services/settings.service';
-import { Settings } from './shared/models/settings';
-
-declare let ga: Function;
-
-@Component({
- selector: 'app-root',
- templateUrl: './app.component.html',
- styleUrls: ['./app.component.scss']
-})
-
-export class AppComponent {
- settings: Settings;
- theme: string;
-
- constructor(
- private _settingsService: SettingsService,
- public router: Router
- ) {
- this.settings = this._settingsService.settings;
- this.router.events.subscribe(event => {
- if (event instanceof NavigationEnd) {
- ga('set', 'page', event.urlAfterRedirects);
- ga('send', 'pageview');
- }
- });
- }
-}
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
deleted file mode 100644
index 5753df61..00000000
--- a/src/app/app.module.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { BrowserModule } from '@angular/platform-browser';
-import { NgModule } from '@angular/core';
-
-import { routing } from './app.routes';
-
-import { AppComponent } from './app.component';
-import { CoreModule } from './core/core.module';
-import { FeedComponent } from './feeds/feed/feed.component';
-import { ItemComponent } from './feeds/item/item.component';
-import { SharedComponentsModule } from './shared/components/shared-components.module';
-import { PipesModule } from './shared/pipes/pipes.module';
-import { ServiceWorkerModule } from '@angular/service-worker';
-import { environment } from '../environments/environment';
-import { HackerNewsAPIService } from './shared/services/hackernews-api.service';
-import { SettingsService } from './shared/services/settings.service';
-
-@NgModule({
- declarations: [AppComponent, FeedComponent, ItemComponent],
- imports: [
- BrowserModule,
- routing,
- CoreModule,
- SharedComponentsModule,
- PipesModule,
- ServiceWorkerModule.register('ngsw-worker.js', {
- enabled: environment.production,
- }),
- ],
- providers: [HackerNewsAPIService, SettingsService],
- bootstrap: [AppComponent],
-})
-export class AppModule {}
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
deleted file mode 100644
index 01df4707..00000000
--- a/src/app/app.routes.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { Routes, RouterModule } from '@angular/router';
-
-import { FeedComponent } from './feeds/feed/feed.component';
-
-const feedRoutes = [{
- path: ':page',
- component: FeedComponent
-}];
-
-const routes: Routes = [
- {path: '', redirectTo: 'news/1', pathMatch: 'full'},
- {
- path: 'news',
- children: feedRoutes,
- data: {feedType: 'news'}
- },
- {
- path: 'newest',
- children: feedRoutes,
- data: {feedType: 'newest'}
- },
- {
- path: 'show',
- children: feedRoutes,
- data: {feedType: 'show'}
- },
- {
- path: 'ask',
- children: feedRoutes,
- data: {feedType: 'ask'}
- },
- {
- path: 'jobs',
- children: feedRoutes,
- data: {feedType: 'jobs'}
- },
- {path: 'item', loadChildren: () => import('./item-details/item-details.module').then(m => m.ItemDetailsModule)},
- {path: 'user', loadChildren: () => import('./user/user.module').then(m => m.UserModule)}
-];
-
-
-// - Updated Export
-export const routing = RouterModule.forRoot(routes);
diff --git a/src/app/core/core.module.ts b/src/app/core/core.module.ts
deleted file mode 100644
index cd00a0c2..00000000
--- a/src/app/core/core.module.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import { NgModule } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import { RouterModule } from '@angular/router';
-import { SettingsComponent } from './settings/settings.component';
-import { HeaderComponent } from './header/header.component';
-import { FooterComponent } from './footer/footer.component';
-
-@NgModule({
- imports: [CommonModule, RouterModule],
- declarations: [HeaderComponent, FooterComponent, SettingsComponent],
- exports: [HeaderComponent, FooterComponent]
-})
-export class CoreModule { }
diff --git a/src/app/core/footer/footer.component.html b/src/app/core/footer/footer.component.html
deleted file mode 100644
index 68d0861e..00000000
--- a/src/app/core/footer/footer.component.html
+++ /dev/null
@@ -1,3 +0,0 @@
-
diff --git a/src/app/core/footer/footer.component.scss b/src/app/core/footer/footer.component.scss
deleted file mode 100644
index 8adac775..00000000
--- a/src/app/core/footer/footer.component.scss
+++ /dev/null
@@ -1,23 +0,0 @@
-@import "../../shared/scss/media";
-@import "../../shared/scss/theme_variables";
-
-#footer {
- position: relative;
- padding: 10px;
- height: 60px;
- letter-spacing: 0.7px;
- text-align: center;
-
- a {
- font-weight: bold;
- text-decoration: none;
-
- &:hover {
- text-decoration: underline;
- }
- }
-
- @media #{$mobile-only} {
- display: none;
- }
-}
diff --git a/src/app/core/footer/footer.component.ts b/src/app/core/footer/footer.component.ts
deleted file mode 100644
index da17d824..00000000
--- a/src/app/core/footer/footer.component.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { Component, OnInit } from '@angular/core';
-
-@Component({
- selector: 'app-footer',
- templateUrl: './footer.component.html',
- styleUrls: ['./footer.component.scss']
-})
-export class FooterComponent implements OnInit {
-
- constructor() { }
-
- ngOnInit() {
- }
-
-}
diff --git a/src/app/core/header/header.component.html b/src/app/core/header/header.component.html
deleted file mode 100644
index 8e01f33e..00000000
--- a/src/app/core/header/header.component.html
+++ /dev/null
@@ -1,25 +0,0 @@
-
diff --git a/src/app/core/header/header.component.scss b/src/app/core/header/header.component.scss
deleted file mode 100644
index 75a262e7..00000000
--- a/src/app/core/header/header.component.scss
+++ /dev/null
@@ -1,149 +0,0 @@
-@import "../../shared/scss/media";
-@import "../../shared/scss/theme_variables";
-
-#header {
- color: #fff;
- padding: 6px 0;
- line-height: 18px;
- vertical-align: middle;
- position: relative;
- z-index: 1;
- width: 100%;
-
- @media #{$mobile-only} {
- height: 50px;
- position: fixed;
- top: 0;
- }
-
- a {
- display: inline;
- }
-}
-
-.home-link {
- width: 50px;
- height: 66px;
-}
-
-.logo-inner {
- width: 32px;
- position: absolute;
- left: 17px;
- top: 18px;
- z-index: -1;
- height: 32px;
- border-radius: 50%;
-
- @media #{$mobile-only} {
- left: 16px;
- top: 12px;
- }
-}
-
-.logo {
- width: 50px;
- padding: 3px 8px 0;
-
- @media #{$mobile-only} {
- width: 45px;
- padding: 0 0 0 10px;
- }
-}
-
-h1 {
- font-weight: normal;
- display: inline-block;
- vertical-align:middle;
- margin: 0;
- font-size: 16px;
-
- a {
- color: #fff;
- text-decoration: none;
- }
-}
-
-.name {
- margin-right: 30px;
- margin-bottom: 2px;
-
- @media #{$mobile-only} {
- display: none;
- }
-}
-
-.header-text {
- position: absolute;
- width: inherit;
- height: 20px;
- left: 10px;
- top: 27px;
- z-index: -1;
-
- @media #{$mobile-only} {
- top: 22px;
- }
-}
-
-.left {
- position: absolute;
- left: 60px;
- font-size: 16px;
-
- @media #{$mobile-only} {
- width: 100%;
- left: 0;
- }
-}
-
-.header-nav {
- display: inline-block;
- margin-left: 20px;
-
- @media #{$mobile-only} {
- margin-left: 60px;
- }
-
- a {
- color: hsla(0,0%,100%,.9);
- text-decoration: none;
- margin: 0 5px;
- letter-spacing: 1.8px;
-
- &:hover {
- color: #fff;
- }
- }
-
- .active {
- color: #fff;
- }
-}
-
-.info {
- position: absolute;
- top: 0;
- right: 20px;
- height: 100%;
-
- @media #{$mobile-only} {
- right: 10px;
- }
-
- img {
- opacity: 0.8;
- width: 25px;
- margin-top: 21.5px;
- display: block;
-
- &:hover {
- opacity: 1;
- cursor: pointer;
- }
-
- @media #{$mobile-only} {
- margin-top: 15px;
- }
- }
-}
diff --git a/src/app/core/header/header.component.ts b/src/app/core/header/header.component.ts
deleted file mode 100644
index 4c518135..00000000
--- a/src/app/core/header/header.component.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { Component, OnInit } from '@angular/core';
-
-import { SettingsService } from '../../shared/services/settings.service';
-import { Settings } from '../../shared/models/settings';
-
-@Component({
- selector: 'app-header',
- templateUrl: './header.component.html',
- styleUrls: ['./header.component.scss']
-})
-export class HeaderComponent implements OnInit {
- settings: Settings;
-
- constructor(private _settingsService: SettingsService) {
- this.settings = this._settingsService.settings;
- }
-
- ngOnInit() {
- }
-
- toggleSettings() {
- this._settingsService.toggleSettings();
- }
-
- scrollTop() {
- window.scrollTo(0, 0);
- }
-}
diff --git a/src/app/core/settings/settings.component.html b/src/app/core/settings/settings.component.html
deleted file mode 100644
index 0156eca5..00000000
--- a/src/app/core/settings/settings.component.html
+++ /dev/null
@@ -1,83 +0,0 @@
-
diff --git a/src/app/core/settings/settings.component.scss b/src/app/core/settings/settings.component.scss
deleted file mode 100644
index 689da658..00000000
--- a/src/app/core/settings/settings.component.scss
+++ /dev/null
@@ -1,74 +0,0 @@
-@import "../../shared/scss/media";
-@import "../../shared/scss/theme_variables";
-
-.overlay {
- position: fixed;
- top: 0;
- bottom: 0;
- left: 0;
- right: 0;
- background: rgba(0, 0, 0, 0.7);
- opacity: 1;
- z-index: 1;
-}
-
-.popup {
- margin: 70px auto;
- padding: 30px;
- border-radius: 5px;
- width: 30%;
- position: relative;
- h1 {
- margin-top: 0;
- margin-bottom: 0px;
- color: #fff;
- text-align: center;
- letter-spacing: 1px;
- }
- h2 {
- padding-top: 10px;
- }
- hr {
- width: 40%;
- margin-bottom: 20px;
- }
- .close {
- position: absolute;
- top: 12px;
- right: 20px;
- font-size: 30px;
- font-weight: bold;
- text-decoration: none;
- color: rgba(255,255,255,0.8);
- &:hover {
- color: #fff;
- cursor: pointer;
- }
- }
- .content {
- max-height: 30%;
- color: #fff;
- letter-spacing: 1px;
- overflow: auto;
- }
- input[type=number] {
- display: block;
- width: 80%;
- height: 20px;
- margin-bottom: 15px;
- border-radius: 5px;
- padding: 2px;
- }
-}
-
-.control-section {
- margin-bottom: 15px;
- padding-bottom: 15px;
- border-bottom: 1px solid white;
-}
-
-@media screen and (max-width: 700px) {
- .box, .popup {
- width: 70%;
- }
-}
diff --git a/src/app/core/settings/settings.component.ts b/src/app/core/settings/settings.component.ts
deleted file mode 100644
index a26f89d9..00000000
--- a/src/app/core/settings/settings.component.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import { Component, OnInit } from '@angular/core';
-
-import { SettingsService } from '../../shared/services/settings.service';
-import { Settings } from '../../shared/models/settings';
-
-@Component({
- selector: 'app-settings',
- templateUrl: './settings.component.html',
- styleUrls: ['./settings.component.scss']
-})
-export class SettingsComponent implements OnInit {
- settings: Settings;
-
- constructor(private _settingsService: SettingsService) {
- this.settings = this._settingsService.settings;
- }
-
- ngOnInit() {
- }
-
- closeSettings() {
- this._settingsService.toggleSettings();
- }
-
- toggleOpenLinksInNewTab() {
- this._settingsService.toggleOpenLinksInNewTab();
- }
-
- selectTheme(theme) {
- this._settingsService.setTheme(theme);
- }
-
- changeTitleFont(val){
- this._settingsService.setFont(val);
- }
-
- changeSpacing(val){
- this._settingsService.setSpacing(val);
- }
-}
diff --git a/src/app/feeds/feed/feed.component.html b/src/app/feeds/feed/feed.component.html
deleted file mode 100644
index df9d4559..00000000
--- a/src/app/feeds/feed/feed.component.html
+++ /dev/null
@@ -1,24 +0,0 @@
-
diff --git a/src/app/feeds/feed/feed.component.scss b/src/app/feeds/feed/feed.component.scss
deleted file mode 100644
index febcd8cd..00000000
--- a/src/app/feeds/feed/feed.component.scss
+++ /dev/null
@@ -1,108 +0,0 @@
-@import "../../shared/scss/media";
-@import "../../shared/scss/theme_variables";
-
-a {
- text-decoration: none;
- font-weight: bold;
-
- &:hover {
- text-decoration: underline;
- };
-}
-
-ol {
- padding: 0 40px;
- margin: 0;
-
- @media #{$mobile-only} {
- box-sizing: border-box;
- list-style: none;
- padding: 0 10px;
- }
-
- li {
- position: relative;
- -webkit-transition: background-color .2s ease;
- transition: background-color .2s ease;
- }
-}
-
-.list-margin {
- @media #{$mobile-only} {
- margin-top: 55px;
- }
-}
-
-.main-content {
- position: relative;
- width: 100%;
- min-height: 100vh;
- -webkit-transition: opacity .2s ease;
- transition: opacity .2s ease;
- box-sizing: border-box;
- padding: 8px 0;
- z-index: 0;
-}
-
-.post {
- padding: 10px 0 10px 5px;
- transition: background-color 0.2s ease;
- border-bottom: 1px solid #CECECB;
-
- .itemNum {
- color: #696969;
- position: absolute;
- width: 30px;
- text-align: right;
- left: 0;
- top: 4px;
- }
-}
-
-.item-block {
- display: block;
-}
-
-
-.nav {
- padding: 10px 40px;
- margin-top: 10px;
- font-size: 17px;
-
- a {
- @media #{$mobile-only} {
- text-decoration: none;
- }
- }
-
- @media #{$mobile-only} {
- margin: 20px 0;
- text-align: center;
- padding: 10px 80px;
- height: 20px;
- }
-
- .prev {
- padding-right: 20px;
-
- @media #{$mobile-only} {
- float: left;
- padding-right: 0;
- }
- }
-
- .more {
- @media #{$mobile-only} {
- float: right;
- }
- }
-}
-
-.job-header {
- font-size: 15px;
- padding: 0 40px 10px;
-
- @media #{$mobile-only} {
- padding: 60px 15px 25px 15px;
- }
-}
diff --git a/src/app/feeds/feed/feed.component.ts b/src/app/feeds/feed/feed.component.ts
deleted file mode 100644
index 7550a0bb..00000000
--- a/src/app/feeds/feed/feed.component.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-import { Component, OnInit } from '@angular/core';
-import { Observable } from 'rxjs';
-import { Subscription } from 'rxjs';
-import { ActivatedRoute } from '@angular/router';
-
-import { HackerNewsAPIService } from '../../shared/services/hackernews-api.service';
-import { Story } from '../../shared/models/story';
-
-@Component({
- selector: 'app-feed',
- templateUrl: './feed.component.html',
- styleUrls: ['./feed.component.scss']
-})
-
-export class FeedComponent implements OnInit {
- typeSub: Subscription;
- pageSub: Subscription;
- items: Story[];
- feedType: string;
- pageNum: number;
- listStart: number;
- errorMessage = '';
-
- constructor(
- private _hackerNewsAPIService: HackerNewsAPIService,
- private route: ActivatedRoute
- ) { }
-
- ngOnInit() {
- this.typeSub = this.route
- .data
- .subscribe(data => {
- this.feedType = (data as any).feedType;
- });
-
- this.pageSub = this.route.params.subscribe(params => {
- this.pageNum = params['page'] ? +params['page'] : 1;
- this._hackerNewsAPIService.fetchFeed(this.feedType, this.pageNum)
- .subscribe(
- items => this.items = items,
- error => this.errorMessage = 'Could not load ' + this.feedType + ' stories.',
- () => {
- this.listStart = ((this.pageNum - 1) * 30) + 1;
- window.scrollTo(0, 0);
- }
- );
- });
- }
-}
diff --git a/src/app/feeds/item/item.component.html b/src/app/feeds/item/item.component.html
deleted file mode 100644
index 1b112319..00000000
--- a/src/app/feeds/item/item.component.html
+++ /dev/null
@@ -1,39 +0,0 @@
-
diff --git a/src/app/feeds/item/item.component.scss b/src/app/feeds/item/item.component.scss
deleted file mode 100644
index 7f985c9c..00000000
--- a/src/app/feeds/item/item.component.scss
+++ /dev/null
@@ -1,68 +0,0 @@
-@import "../../shared/scss/media";
-@import "../../shared/scss/theme_variables";
-
-p {
- margin: 2px 0;
-
- @media #{$mobile-only} {
- margin-bottom: 5px;
- margin-top: 0;
- }
- }
-
- a {
- cursor: pointer;
- text-decoration: none;
- }
-
- .title {
- font-size: 16px;
- font-family: Verdana, Geneva, sans-serif;
- }
-
- .subtext-laptop {
- font-size: 12px;
- font-weight: bold;
- letter-spacing: 0.5px;
-
- a {
- &:hover {
- text-decoration: underline;
- };
- }
- @media #{$mobile-only} {
- display: none;
- }
- }
-
- .subtext-palm {
- font-size: 13px;
- font-weight: bold;
- letter-spacing: 0.5px;
-
- a {
- &:hover {
- text-decoration: underline;
- };
- }
-
- .details {
- margin-top: 5px;
-
- .right {
- float: right;
- }
- }
- @media #{$laptop-only} {
- display: none;
- }
- }
-
- .domain {
- color: #696969;
- letter-spacing: 0.5px;
- }
-
- .item-details {
- padding: 10px;
- }
diff --git a/src/app/feeds/item/item.component.ts b/src/app/feeds/item/item.component.ts
deleted file mode 100644
index 8a1bd978..00000000
--- a/src/app/feeds/item/item.component.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { Component, Input, OnInit } from '@angular/core';
-import { Story } from '../../shared/models/story';
-
-import { SettingsService } from '../../shared/services/settings.service';
-import { Settings } from '../../shared/models/settings';
-
-@Component({
- selector: 'item',
- templateUrl: './item.component.html',
- styleUrls: ['./item.component.scss']
-})
-export class ItemComponent implements OnInit {
- @Input() item: Story;
- settings: Settings;
-
- constructor(private _settingsService: SettingsService) {
- this.settings = this._settingsService.settings;
- }
-
- ngOnInit() {}
-
- get hasUrl(): boolean {
- return this.item.url.indexOf('http') === 0;
- }
-
-}
diff --git a/src/app/item-details/comment/comment.component.html b/src/app/item-details/comment/comment.component.html
deleted file mode 100644
index 6137a8cd..00000000
--- a/src/app/item-details/comment/comment.component.html
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
- [deleted] | Comment Deleted
-
-
\ No newline at end of file
diff --git a/src/app/item-details/comment/comment.component.scss b/src/app/item-details/comment/comment.component.scss
deleted file mode 100644
index 83992b13..00000000
--- a/src/app/item-details/comment/comment.component.scss
+++ /dev/null
@@ -1,85 +0,0 @@
-@import "../../shared/scss/media";
-@import "../../shared/scss/theme_variables";
-
-:host >>> {
- a {
- font-weight: bold;
- text-decoration: none;
- &:hover {
- text-decoration: underline;
- }
- }
-}
-
-.meta {
- font-size: 13px;
- color: #696969;
- font-weight: bold;
- letter-spacing: 0.5px;
- margin-bottom: 8px;
- a {
- text-decoration: none;
-
- &:hover {
- text-decoration: underline;
- }
- }
- .time {
- padding-left: 5px;
- }
-}
-
-@media #{$mobile-only} {
- .meta {
- font-size: 14px;
- margin-bottom: 10px;
- .time {
- padding: 0;
- float: right;
- }
- }
-}
-
-.meta-collapse {
- margin-bottom: 20px;
-}
-
-.deleted-meta {
- font-size: 12px;
- font-weight: bold;
- letter-spacing: 0.5px;
- margin: 30px 0;
- a {
- text-decoration: none;
- }
-}
-
-.collapse {
- font-size: 13px;
- letter-spacing: 2px;
- cursor: pointer;
-}
-
-.comment-tree {
- margin-left: 24px;
-}
-
-@media #{$tablet-only} {
- .comment-tree {
- margin-left: 8px;
- }
-}
-
-.comment-text {
- font-size: 15px;
- margin-top: 0;
- margin-bottom: 20px;
- word-wrap: break-word;
- line-height: 1.5em;
-}
-
-.subtree {
- margin-left: 0;
- padding: 0;
- list-style-type: none;
-}
diff --git a/src/app/item-details/comment/comment.component.ts b/src/app/item-details/comment/comment.component.ts
deleted file mode 100644
index c6b4da56..00000000
--- a/src/app/item-details/comment/comment.component.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { Component, Input, OnInit } from '@angular/core';
-
-import { Comment } from '../../shared/models/comment';
-
-@Component({
- selector: 'app-comment',
- templateUrl: './comment.component.html',
- styleUrls: ['./comment.component.scss']
-})
-export class CommentComponent implements OnInit {
- @Input() comment: Comment;
- collapse: boolean;
-
- constructor() {}
-
- ngOnInit() {
- this.collapse = false;
- }
-}
diff --git a/src/app/item-details/item-details.component.html b/src/app/item-details/item-details.component.html
deleted file mode 100644
index 0bf73ae0..00000000
--- a/src/app/item-details/item-details.component.html
+++ /dev/null
@@ -1,59 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
{{pollResult.points}} points
-
-
-
-
-
-
-
diff --git a/src/app/item-details/item-details.component.scss b/src/app/item-details/item-details.component.scss
deleted file mode 100644
index adde0bd7..00000000
--- a/src/app/item-details/item-details.component.scss
+++ /dev/null
@@ -1,151 +0,0 @@
-@import "../shared/scss/media";
-@import "../shared/scss/theme_variables";
-
-.main-content {
- position: relative;
- width: 100%;
- min-height: 100vh;
- -webkit-transition: opacity .2s ease;
- transition: opacity .2s ease;
- box-sizing: border-box;
- padding: 8px 0;
- z-index: 0;
-}
-
-.item {
- box-sizing: border-box;
- padding: 10px 40px 0 40px;
- z-index: 0;
-}
-
-@media #{$tablet-only} {
- .item {
- padding: 10px 20px 0 40px;
- }
-}
-
-@media #{$mobile-only} {
- .item {
- box-sizing: border-box;
- padding: 110px 15px 0 15px;
- }
-}
-
-.head-margin {
- margin-bottom: 15px;
-}
-
-p {
- margin: 2px 0;
-}
-
-.subject {
- word-wrap: break-word;
- margin-top: 20px;
-}
-
-a {
- cursor: pointer;
- text-decoration: none;
-}
-
-@media #{$mobile-only} {
- .laptop {
- display: none;
- }
-}
-
-@media #{$laptop-only} {
- .mobile {
- display: none;
- }
-}
-
-.title {
- font-size: 16px;
- font-family: Verdana, Geneva, sans-serif;
-}
-
-.title-block {
- text-align: center;
- text-overflow: ellipsis;
- white-space: nowrap;
- overflow: hidden;
- margin: 0 75px;
-}
-
-@media #{$mobile-only} {
- .title {
- font-size: 15px;
- }
- .back-button {
- position: absolute;
- top: 52%;
- width: 0.6rem;
- height: 0.6rem;
- background: transparent;
- box-shadow: 0 0 0 lightgray;
- transition: all 200ms ease;
- left: 4%;
- transform: translate3d(0, -50%, 0) rotate(-135deg);
- }
-}
-
-.subtext {
- font-size: 12px;
- font-weight: bold;
- letter-spacing: 0.5px;
-}
-
-.domain {
- letter-spacing: 0.5px;
-}
-
-.subtext a {
- &:hover {
- text-decoration: underline;
- }
-}
-
-.item-details {
- padding: 10px;
-}
-
-.item-header {
- padding-bottom: 10px;
-}
-
-@media #{$mobile-only} {
- .item-header {
- padding: 10px 0 10px 0;
- position: fixed;
- width: 100%;
- left: 0;
- top: 62px;
- }
-}
-
-.pollResults {
- margin-bottom: 1em;
-}
-
-.pollContent {
- * {
- padding-bottom: 0;
- margin-bottom: -1em;
- margin-top: 1em;
- }
- .pollBar {
- height: 10px;
- margin-bottom: 1em;
- }
-}
-
-ul {
- list-style-type: none;
- padding: 10px 0;
-}
-
-li {
- display: list-item;
-}
diff --git a/src/app/item-details/item-details.component.ts b/src/app/item-details/item-details.component.ts
deleted file mode 100644
index 88e6199f..00000000
--- a/src/app/item-details/item-details.component.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import { Component, OnInit } from '@angular/core';
-import { ActivatedRoute } from '@angular/router';
-import { Location } from '@angular/common';
-import { Subscription } from 'rxjs/Subscription';
-
-import { HackerNewsAPIService } from '../shared/services/hackernews-api.service';
-import { SettingsService } from '../shared/services/settings.service';
-
-import { Story } from '../shared/models/story';
-import { Settings } from '../shared/models/settings';
-
-@Component({
- selector: 'app-item-details',
- templateUrl: './item-details.component.html',
- styleUrls: ['./item-details.component.scss']
-})
-export class ItemDetailsComponent implements OnInit {
- sub: Subscription;
- item: Story;
- errorMessage = '';
- settings: Settings;
-
- constructor(
- private _hackerNewsAPIService: HackerNewsAPIService,
- private _settingsService: SettingsService,
- private route: ActivatedRoute,
- private _location: Location
- ) {
- this.settings = this._settingsService.settings;
- }
-
- ngOnInit() {
- this.sub = this.route.params.subscribe(params => {
- let itemID = +params['id'];
- this._hackerNewsAPIService.fetchItemContent(itemID).subscribe(item => {
- this.item = item;
- }, error => this.errorMessage = 'Could not load item comments.');
- });
- window.scrollTo(0, 0);
- }
-
- goBack() {
- this._location.back();
- }
-
- get hasUrl(): boolean {
- return this.item.url.indexOf('http') === 0;
- }
-
-}
diff --git a/src/app/item-details/item-details.module.ts b/src/app/item-details/item-details.module.ts
deleted file mode 100644
index 31c98293..00000000
--- a/src/app/item-details/item-details.module.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import { NgModule } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import { RouterModule, Routes } from '@angular/router';
-import { ItemDetailsComponent } from './item-details.component';
-import { CommentComponent } from './comment/comment.component';
-import { SharedComponentsModule } from '../shared/components/shared-components.module';
-import { PipesModule } from '../shared/pipes/pipes.module';
-
-
-const routes: Routes = [
- {
- path: ':id',
- component: ItemDetailsComponent
- }
-]
-
-@NgModule({
- imports: [ SharedComponentsModule, CommonModule, RouterModule, PipesModule, RouterModule.forChild(routes) ],
- declarations: [ ItemDetailsComponent, CommentComponent ],
- exports: [ ItemDetailsComponent, RouterModule]
-})
-export class ItemDetailsModule {}
diff --git a/src/app/shared/components/error-message/error-message.component.html b/src/app/shared/components/error-message/error-message.component.html
deleted file mode 100644
index 3c8f9fb6..00000000
--- a/src/app/shared/components/error-message/error-message.component.html
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
{{ message }}
-
If you are offline viewing, you'll need to visit this page with a network connection first before it can work offline.
-
diff --git a/src/app/shared/components/error-message/error-message.component.scss b/src/app/shared/components/error-message/error-message.component.scss
deleted file mode 100644
index eb5cc01e..00000000
--- a/src/app/shared/components/error-message/error-message.component.scss
+++ /dev/null
@@ -1,115 +0,0 @@
-@import "../../scss/media";
-@import "../../scss/theme_variables";
-
-.error-section {
- height: 300px;
- margin: 200px;
-
- @media #{$mobile-only} {
- height: 0;
- display: block;
- position: relative;
- margin: 30vh 0;
- }
-
- p {
- text-align: center;
- padding: 0 25px;
-
- &.strong {
- margin-top: 25px;
- font-weight: bold;
- }
- }
-
- .skull {
- width: $skull-size;
- height: $skull-size;
- position: relative;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- margin: auto;
-
- .head {
- width: 100%;
- height: 75%;
- border-radius: 15% / 20%;
- position: absolute;
- top: 0;
- left: 0;
- &:before, &:after {
- content: "";
- position: absolute;
- border-radius: 50%;
- width: 20%;
- height: 30%;
- bottom: 10%;
- }
- &:before {
- left: 10%;
- }
- &:after {
- right: 10%;
- }
- .crack {
- width: 10%;
- height: 10%;
- position: absolute;
- top: 0;
- right: 25%;
- transform: skew(-15deg);
-
- &:before {
- content: "";
- position: absolute;
- top: 100%;
- left: $skull-size / 15;
- border-right: $skull-size / 20 solid transparent;
- border-left: $skull-size / 40 solid transparent;
- }
- }
- }
- .mouth {
- width: 40%;
- height: 25%;
- position: absolute;
- top: 75%;
- left: 30%;
- border-radius: 0 0 $skull-size / 10 $skull-size / 10;
- &:before {
- content: "";
- position: absolute;
- width: 15%;
- height: 50%;
- border-radius: 50% / 30%;
- left: 42.5%;
- top: -25%;
- }
- .teeth {
- position: absolute;
- bottom: 0;
- left: 45%;
- width: 10%;
- height: 50%;
- margin-bottom: -5%;
- border-radius: 50% / 20%;
-
- &:before, &:after {
- content: "";
- position: absolute;
- width: 100%;
- height: 100%;
- border-radius: 50% / 20%;
- }
- &:before {
- left: -250%;
- }
- &:after {
- right: -250%;
- }
- }
- }
- }
-}
diff --git a/src/app/shared/components/error-message/error-message.component.ts b/src/app/shared/components/error-message/error-message.component.ts
deleted file mode 100644
index cbd1219f..00000000
--- a/src/app/shared/components/error-message/error-message.component.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { Component, Input, OnInit } from '@angular/core';
-
-@Component({
- selector: 'app-error-message',
- templateUrl: './error-message.component.html',
- styleUrls: ['./error-message.component.scss']
-})
-export class ErrorMessageComponent implements OnInit {
- @Input() message: string;
-
- constructor() { }
-
- ngOnInit() {
- }
-
-}
diff --git a/src/app/shared/components/loader/loader.component.html b/src/app/shared/components/loader/loader.component.html
deleted file mode 100644
index 0db7fc43..00000000
--- a/src/app/shared/components/loader/loader.component.html
+++ /dev/null
@@ -1,5 +0,0 @@
-
diff --git a/src/app/shared/components/loader/loader.component.scss b/src/app/shared/components/loader/loader.component.scss
deleted file mode 100644
index 625d0fe8..00000000
--- a/src/app/shared/components/loader/loader.component.scss
+++ /dev/null
@@ -1,109 +0,0 @@
-@import "../../scss/media";
-@import "../../scss/theme_variables";
-
-.loader {
- -webkit-animation: load1 1s infinite ease-in-out;
- animation: load1 1s infinite ease-in-out;
- width: 1em;
- height: 4em;
- &:before, &:after {
- -webkit-animation: load1 1s infinite ease-in-out;
- animation: load1 1s infinite ease-in-out;
- width: 1em;
- height: 4em;
- }
- &:before, &:after {
- position: absolute;
- top: 0;
- content: '';
- }
- &:before {
- left: -1.5em;
- -webkit-animation-delay: -0.32s;
- animation-delay: -0.32s;
- }
-}
-
-.loading-section {
- height: 70px;
- margin: 40px 0 40px 40px;
-
- @media #{$mobile-only} {
- display: block;
- position: relative;
- margin: 45vh 0;
- }
-}
-
-.loader {
- text-indent: -9999em;
- margin: 20px 20px;
- position: relative;
- font-size: 11px;
- -webkit-transform: translateZ(0);
- -ms-transform: translateZ(0);
- transform: translateZ(0);
- -webkit-animation-delay: -0.16s;
- animation-delay: -0.16s;
- &:after {
- left: 1.5em;
- }
-
- @media #{$mobile-only} {
- margin: 20px auto;
- }
-}
-
-@-webkit-keyframes load1 {
- 0%,
- 80%,
- 100% {
- box-shadow: 0 0;
- height: 2em;
- }
- 40% {
- box-shadow: 0 -2em;
- height: 3em;
- }
-}
-
-@keyframes load1 {
- 0%,
- 80%,
- 100% {
- box-shadow: 0 0;
- height: 2em;
- }
- 40% {
- box-shadow: 0 -2em;
- height: 3em;
- }
-}
-
-@media #{$mobile-only} {
- @-webkit-keyframes load1 {
- 0%,
- 80%,
- 100% {
- box-shadow: 0 0;
- height: 4em;
- }
- 40% {
- box-shadow: 0 -2em;
- height: 5em;
- }
- }
-
- @keyframes load1 {
- 0%,
- 80%,
- 100% {
- box-shadow: 0 0;
- height: 3em;
- }
- 40% {
- box-shadow: 0 -2em;
- height: 4em;
- }
- }
-}
diff --git a/src/app/shared/components/loader/loader.component.ts b/src/app/shared/components/loader/loader.component.ts
deleted file mode 100644
index b1c2ed7c..00000000
--- a/src/app/shared/components/loader/loader.component.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { Component, OnInit } from '@angular/core';
-
-@Component({
- selector: 'app-loader',
- templateUrl: './loader.component.html',
- styleUrls: ['./loader.component.scss']
-})
-export class LoaderComponent implements OnInit {
-
- constructor() { }
-
- ngOnInit() {
- }
-
-}
diff --git a/src/app/shared/components/shared-components.module.ts b/src/app/shared/components/shared-components.module.ts
deleted file mode 100644
index 97577eef..00000000
--- a/src/app/shared/components/shared-components.module.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { CommonModule } from '@angular/common';
-import { NgModule } from '@angular/core';
-import { LoaderComponent } from './loader/loader.component';
-import { ErrorMessageComponent } from './error-message/error-message.component';
-
-@NgModule({
- imports: [CommonModule],
- declarations: [ LoaderComponent, ErrorMessageComponent ],
- exports: [ LoaderComponent, ErrorMessageComponent ]
-})
-export class SharedComponentsModule {}
diff --git a/src/app/shared/models/comment.ts b/src/app/shared/models/comment.ts
deleted file mode 100644
index afc289a2..00000000
--- a/src/app/shared/models/comment.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export class Comment {
- id: number;
- level: number;
- user: string;
- time: number;
- time_ago: string;
- content: string;
- deleted: boolean;
- comments: Comment[];
-}
diff --git a/src/app/shared/models/poll-result.ts b/src/app/shared/models/poll-result.ts
deleted file mode 100644
index bd8977d4..00000000
--- a/src/app/shared/models/poll-result.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export class PollResult {
- points: number;
- content: string;
-}
diff --git a/src/app/shared/models/settings.ts b/src/app/shared/models/settings.ts
deleted file mode 100644
index 8a0f3ea1..00000000
--- a/src/app/shared/models/settings.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-export interface Settings {
- showSettings: boolean;
- openLinkInNewTab: boolean;
- theme: string;
- titleFontSize: string;
- listSpacing: string;
-}
diff --git a/src/app/shared/models/story.ts b/src/app/shared/models/story.ts
deleted file mode 100644
index dab22e18..00000000
--- a/src/app/shared/models/story.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { Comment } from './comment';
-import { FeedType } from './feed-type.type';
-import { PollResult } from './poll-result';
-
-export class Story {
- id: number;
- title: string;
- points: number;
- user: string;
- time: number;
- time_ago: number;
- type: FeedType;
- url: string;
- domain: string;
- comments: Comment[];
- comments_count: number;
- poll: PollResult[];
- poll_votes_count: number;
- deleted: boolean;
- dead: boolean;
-}
diff --git a/src/app/shared/models/user.ts b/src/app/shared/models/user.ts
deleted file mode 100644
index 33f0a7aa..00000000
--- a/src/app/shared/models/user.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-export class User {
- id: string;
- crated_time: number;
- created: string;
- karma: number;
- avg: number;
- about: string;
-}
diff --git a/src/app/shared/pipes/comment.pipe.ts b/src/app/shared/pipes/comment.pipe.ts
deleted file mode 100644
index 42c91e4e..00000000
--- a/src/app/shared/pipes/comment.pipe.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import {Pipe, PipeTransform} from '@angular/core';
-
-@Pipe({
- name: 'comment',
- pure: true
-})
-export class CommentPipe implements PipeTransform {
- transform(comment: number): string {
- if (comment > 0) {
- let st = comment === 1 ? 'comment' : 'comments';
- return `${comment} ${st}`;
- }
- return 'discuss';
- }
-}
diff --git a/src/app/shared/pipes/pipes.module.ts b/src/app/shared/pipes/pipes.module.ts
deleted file mode 100644
index 0720c237..00000000
--- a/src/app/shared/pipes/pipes.module.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-import { NgModule } from '@angular/core';
-import { CommentPipe } from './comment.pipe';
-
-@NgModule({
- declarations: [CommentPipe],
- exports: [CommentPipe]
-})
-export class PipesModule {}
diff --git a/src/app/shared/scss/_media.scss b/src/app/shared/scss/_media.scss
deleted file mode 100644
index b04b71a9..00000000
--- a/src/app/shared/scss/_media.scss
+++ /dev/null
@@ -1,3 +0,0 @@
-$mobile-only: "only screen and (max-width : 768px)";
-$laptop-only: "only screen and (min-width : 769px)";
-$tablet-only: "only screen and (max-width : 1024px)";
diff --git a/src/app/shared/scss/_theme_variables.scss b/src/app/shared/scss/_theme_variables.scss
deleted file mode 100644
index 41dae5ee..00000000
--- a/src/app/shared/scss/_theme_variables.scss
+++ /dev/null
@@ -1,37 +0,0 @@
-$skull-size: 200px;
-
-// -------------------------------------------------------------------------------------------
-// Theme Engine
-// -------------------------------------------------------------------------------------------
-
-// Day theme colors
-$theme-day-body-background-color: #fff;
-$theme-day-wrapper-background-color: #f5f5f5;
-$theme-day-wrapper-mobile-background-color: #fff;
-$theme-day-text-color: #000;
-$theme-day-subtext-color: #696969;
-$theme-day-secondary-color: #b92b27;
-$theme-day-header-background-color: $theme-day-secondary-color;
-$theme-day-logo-inner: #fff;
-
-// Day theme extras
-$theme-day-border: 2px solid #b92b27;
-
-// Night theme colors
-$theme-night-body-background-color: #37474F;
-$theme-night-wrapper-background-color: #263238;
-$theme-night-wrapper-mobile-background-color: $theme-night-wrapper-background-color;
-$theme-night-text-color: rgba(255, 255, 255, 0.7);
-$theme-night-subtext-color: #999;
-$theme-night-secondary-color: #00c0ff;
-$theme-night-header-background-color: #263238;
-$theme-night-logo-inner: $theme-night-header-background-color;
-
-// Night theme extras
-$theme-night-border: 2px solid #00c0ff;
-
-// Black theme colors
-$theme-amoledblack-body-background-color: #000;
-$theme-amoledblack-text-color: rgba(255, 255, 255, 0.75);
-$theme-amoledblack-subtext-color: rgba(255, 255, 255, 0.5);
-$theme-amoledblack-secondary-color: rgba(255, 255, 255, 0.6);
diff --git a/src/app/shared/scss/_themes.scss b/src/app/shared/scss/_themes.scss
deleted file mode 100644
index 231ed7cf..00000000
--- a/src/app/shared/scss/_themes.scss
+++ /dev/null
@@ -1,245 +0,0 @@
-@import "./media";
-@import "./theme_variables";
-
-/* ----------------------------------
-
- Want a new theme? Add your new theme variables here!
-
----------------------------------- */
-
-@mixin theme(
- $name,
- $body-background-color,
- $wrapper-background-color,
- $wrapper-mobile-background-color,
- $wrapper-color,
- $item-a-color,
- $item-a-visited-color,
- $header-background-color,
- $subtext-color,
- $secondary-link-color,
- $logo-inner-color,
- $border
-) {
- .#{$name} {
- .body-cover {
- background: $body-background-color;
-
- @media #{$mobile-only} {
- background: $wrapper-mobile-background-color;
- }
- }
-
- .wrapper {
- background: $wrapper-background-color;
- color: $wrapper-color;
-
- @media #{$mobile-only} {
- background: $wrapper-mobile-background-color;
- }
-
- a {
- color: $item-a-color;
-
- &:visited {
- color: $item-a-visited-color;
- }
- }
-
- #header {
- background: $header-background-color;
- border-bottom: $border;
- }
-
- .logo-inner {
- background: $logo-inner-color;
- }
-
- .nav {
- a {
- color: $secondary-link-color;
-
- @media #{$mobile-only} {
- color: $secondary-link-color;
- }
- }
- }
-
- #footer {
- border-top: $border;
-
- a {
- color: $secondary-link-color;
- }
- }
-
- .subtext, .subtext-palm, .subtext-laptop, .domain, .meta, .deleted-meta{
- color: $subtext-color;
-
- a {
- color: $secondary-link-color;
- }
- }
-
- .popup {
- background: $header-background-color;
- }
-
- .item-header {
- border-bottom: $border;
-
- @media #{$mobile-only} {
- background: $wrapper-mobile-background-color;
- }
- }
-
- .pollContent {
- .pollBar {
- background: $secondary-link-color;
- }
- }
-
- .loader {
- color: $secondary-link-color;
- background: $secondary-link-color;
-
- &:before, &:after {
- background: $secondary-link-color;
- }
- }
-
- .job-header {
- @media #{$mobile-only} {
- border-bottom: $border;
- }
- }
-
- .back-button {
- @media #{$mobile-only} {
- border-top: .3rem solid $secondary-link-color;
- border-right: .3rem solid $secondary-link-color;
- }
- }
-
- .error-section {
- .skull {
- .head {
- background-color: $secondary-link-color;
-
- &:before, &:after {
- background-color: $wrapper-background-color;
-
- @media #{$mobile-only} {
- background-color: $wrapper-mobile-background-color;
- }
- }
-
- .crack {
- background-color: $wrapper-background-color;
-
- @media #{$mobile-only} {
- background-color: $wrapper-mobile-background-color;
- }
-
- &:before {
- border-top: $skull-size / 8 solid $wrapper-background-color;
-
- @media #{$mobile-only} {
- border-top: $skull-size / 8 solid $wrapper-mobile-background-color;
- }
- }
- }
- }
-
- .mouth {
- background-color: $secondary-link-color;
-
- &:before {
- background-color: $wrapper-background-color;
-
- @media #{$mobile-only} {
- background-color: $wrapper-mobile-background-color;
- }
- }
- }
-
- .teeth {
- background-color: $wrapper-background-color;
-
- @media #{$mobile-only} {
- background-color: $wrapper-mobile-background-color;
- }
-
- &:before, &:after {
- background-color: $wrapper-background-color;
-
- @media #{$mobile-only} {
- background-color: $wrapper-mobile-background-color;
- }
- }
- }
- }
- }
-
- .main-details {
- .name {
- color: $secondary-link-color;
- }
- .right {
- color: $secondary-link-color;
- }
- }
- }
- }
-}
-
-@include theme(
- default,
- $theme-day-body-background-color,
- $theme-day-wrapper-background-color,
- $theme-day-wrapper-mobile-background-color,
- $theme-day-text-color,
- $theme-day-text-color,
- $theme-day-subtext-color,
- $theme-day-header-background-color,
- $theme-day-subtext-color,
- $theme-day-secondary-color,
- $theme-day-logo-inner,
- $theme-day-border
-);
-
-@include theme(
- night,
- $theme-night-body-background-color,
- $theme-night-wrapper-background-color,
- $theme-night-wrapper-mobile-background-color,
- $theme-night-text-color,
- $theme-night-text-color,
- $theme-night-subtext-color,
- $theme-night-header-background-color,
- $theme-night-subtext-color,
- $theme-night-secondary-color,
- $theme-night-logo-inner,
- $theme-night-border
-);
-
-@include theme(
- amoledblack,
- $theme-amoledblack-body-background-color,
- $theme-amoledblack-body-background-color,
- $theme-amoledblack-body-background-color,
- $theme-amoledblack-text-color,
- $theme-amoledblack-text-color,
- darken($theme-amoledblack-text-color, 33%),
- $theme-amoledblack-body-background-color,
- $theme-amoledblack-subtext-color,
- $theme-amoledblack-secondary-color,
- $theme-amoledblack-body-background-color,
- $theme-amoledblack-secondary-color
-);
-
-/* ----------------------------------
-
- Include your new theme here as well as the settings component
-
----------------------------------- */
diff --git a/src/app/shared/services/hackernews-api.service.ts b/src/app/shared/services/hackernews-api.service.ts
deleted file mode 100644
index e5c56536..00000000
--- a/src/app/shared/services/hackernews-api.service.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-import { Injectable } from '@angular/core';
-import { Observable } from 'rxjs/Observable';
-import fetch from 'unfetch';
-import {map } from 'rxjs/operators';
-
-import { Story } from '../models/story';
-import { User } from '../models/user';
-import { PollResult } from '../models/poll-result';
-
-// wrap fetch in observable so we can keep it chill
-@Injectable()
-export class HackerNewsAPIService {
- baseUrl: string;
-
- constructor() {
- this.baseUrl = 'https://node-hnapi.herokuapp.com';
- }
-
- fetchFeed(feedType: string, page: number): Observable {
- return lazyFetch(`${this.baseUrl}/${feedType}?page=${page}`);
- }
-
- fetchItemContent(id: number): Observable {
- return lazyFetch(`${this.baseUrl}/item/${id}`).pipe(map((story: Story) => {
- if (story.type === 'poll') {
- let numberOfPollOptions = story.poll.length;
- story.poll_votes_count = 0;
- for (let i = 1; i <= numberOfPollOptions; i++) {
- this.fetchPollContent(story.id + i).subscribe(pollResults => {
- story.poll[i - 1] = pollResults;
- story.poll_votes_count += pollResults.points;
- });
- }
- }
- return story;
- }));
- }
-
- fetchPollContent(id: number): Observable {
- return lazyFetch(`${this.baseUrl}/item/${id}`);
- }
-
- fetchUser(id: string): Observable {
- return lazyFetch(`${this.baseUrl}/user/${id}`);
- }
-}
-
-function lazyFetch(url, options?) {
- return new Observable(fetchObserver => {
- let cancelToken = false;
- fetch(url, options)
- .then(res => {
- if (!cancelToken) {
- return res.json()
- .then(data => {
- fetchObserver.next(data);
- fetchObserver.complete();
- });
- }
- }).catch(err => fetchObserver.error(err));
- return () => {
- cancelToken = true;
- };
- });
-}
-
diff --git a/src/app/shared/services/settings.service.ts b/src/app/shared/services/settings.service.ts
deleted file mode 100644
index 0ad7ade5..00000000
--- a/src/app/shared/services/settings.service.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import { Injectable } from '@angular/core';
-
-import { Settings } from '../models/settings';
-
-@Injectable({
- providedIn: 'root'
-})
-export class SettingsService {
- settings: Settings = {
- showSettings : false,
- openLinkInNewTab: localStorage.getItem("openLinkInNewTab") ? JSON.parse(localStorage.getItem("openLinkInNewTab")) : false,
- theme: 'default',
- titleFontSize: localStorage.getItem("titleFontSize") ? localStorage.getItem("titleFontSize") : '16',
- listSpacing: localStorage.getItem("listSpacing") ? localStorage.getItem("listSpacing") : '0',
- };
-
- darkColorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)');
-
- constructor() {
- this.subscribeToSystemPreferredColorScheme();
- this.initTheme();
- }
-
- ngOnDestroy() {
- this.unSubscribeToSystemPrefferedColorScheme();
- }
-
- handleSystemPreferredColorSchemeChange(event: MediaQueryListEvent) {
- let theme;
- if (event.matches) {
- theme = 'night';
- } else {
- theme = 'default';
- }
- this.setTheme(theme);
- }
-
- subscribeToSystemPreferredColorScheme() {
- this.darkColorSchemeMedia.addEventListener(
- 'change',
- this.handleSystemPreferredColorSchemeChange.bind(this)
- );
- }
-
- initTheme() {
- const savedTheme = localStorage.getItem("theme");
- if (savedTheme) {
- this.settings.theme = savedTheme;
- } else {
- this.darkColorSchemeMedia.dispatchEvent(
- new MediaQueryListEvent('change', {
- media: this.darkColorSchemeMedia.media,
- matches: this.darkColorSchemeMedia.matches
- })
- );
- }
- }
-
- unSubscribeToSystemPrefferedColorScheme() {
- this.darkColorSchemeMedia.removeEventListener(
- 'change',
- this.handleSystemPreferredColorSchemeChange.bind(this)
- );
- }
-
- toggleSettings() {
- this.settings.showSettings = !this.settings.showSettings;
- }
-
- toggleOpenLinksInNewTab() {
- this.settings.openLinkInNewTab = !this.settings.openLinkInNewTab;
- localStorage.setItem("openLinkInNewTab", JSON.stringify(this.settings.openLinkInNewTab));
- }
-
- setTheme(theme) {
- this.settings.theme = theme;
- localStorage.setItem("theme", this.settings.theme);
- }
-
- setFont(fontSize){
- this.settings.titleFontSize = fontSize;
- localStorage.setItem("titleFontSize", this.settings.titleFontSize);
- }
-
- setSpacing(listSpace){
- this.settings.listSpacing = listSpace;
- localStorage.setItem("listSpacing", this.settings.listSpacing);
- }
-}
diff --git a/src/app/user/user.component.html b/src/app/user/user.component.html
deleted file mode 100644
index bd6f0493..00000000
--- a/src/app/user/user.component.html
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
-
-
-
-
{{ user.id }}
-
{{ user.karma }} ★
-
Created {{ user.created }}
-
-
-
diff --git a/src/app/user/user.component.scss b/src/app/user/user.component.scss
deleted file mode 100644
index c5fc9af5..00000000
--- a/src/app/user/user.component.scss
+++ /dev/null
@@ -1,89 +0,0 @@
-@import "../shared/scss/media";
-@import "../shared/scss/theme_variables";
-
-:host >>> pre {
- white-space: pre-wrap;
-}
-
-.profile {
- padding: 30px;
-}
-
-@media #{$mobile-only} {
- .profile {
- padding: 110px 15px 0 15px;
- }
- .title-block {
- font-size: 15px;
- text-align: center;
- text-overflow: ellipsis;
- white-space: nowrap;
- overflow: hidden;
- margin: 0 75px;
- }
- .back-button {
- position: absolute;
- top: 52%;
- width: 0.6rem;
- height: 0.6rem;
- background: transparent;
- box-shadow: 0 0 0 lightgray;
- transition: all 200ms ease;
- left: 4%;
- transform: translate3d(0, -50%, 0) rotate(-135deg);
- }
- .item-header {
- padding-bottom: 10px;
- background-color: #fff;
- padding: 10px 0 10px 0;
- position: fixed;
- width: 100%;
- left: 0;
- top: 62px;
- height: 20px;
- }
-}
-
-@media #{$laptop-only} {
- .mobile {
- display: none;
- }
-}
-
-.main-details {
- .name {
- font-weight: bold;
- font-size: 32px;
- letter-spacing: 2px;
- }
- .age {
- font-weight: bold;
- color: #696969;
- padding-bottom: 0;
- }
- .right {
- float: right;
- font-weight: bold;
- font-size: 32px;
- letter-spacing: 2px;
- }
-}
-
-@media #{$mobile-only} {
- .main-details {
- margin-top: 20px;
- .name {
- font-size: 18px;
- }
- }
-}
-
-@media #{$mobile-only} {
- .main-details .right {
- font-size: 18px;
- }
-}
-
-.other-details {
- word-wrap: break-word;
-}
diff --git a/src/app/user/user.component.ts b/src/app/user/user.component.ts
deleted file mode 100644
index 28e90a4b..00000000
--- a/src/app/user/user.component.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { Component, OnInit } from '@angular/core';
-import { ActivatedRoute } from '@angular/router';
-import { Location } from '@angular/common';
-import { Subscription } from 'rxjs/Subscription';
-
-import { HackerNewsAPIService } from '../shared/services/hackernews-api.service';
-import { User } from '../shared/models/user';
-
-@Component({
- selector: 'app-user',
- templateUrl: './user.component.html',
- styleUrls: ['./user.component.scss']
-})
-export class UserComponent implements OnInit {
- sub: Subscription;
- user: User;
- errorMessage = '';
-
- constructor(
- private _hackerNewsAPIService: HackerNewsAPIService,
- private route: ActivatedRoute,
- private _location: Location
- ) {}
-
- ngOnInit() {
- this.sub = this.route.params.subscribe(params => {
- let userID = params['id'];
- this._hackerNewsAPIService.fetchUser(userID).subscribe(data => {
- this.user = data;
- }, error => this.errorMessage = 'Could not load user ' + userID + '.');
- });
- }
-
- goBack() {
- this._location.back();
- }
-}
diff --git a/src/app/user/user.module.ts b/src/app/user/user.module.ts
deleted file mode 100644
index 79615f52..00000000
--- a/src/app/user/user.module.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { NgModule } from '@angular/core';
-import { CommonModule } from '@angular/common';
-import { Routes, RouterModule } from '@angular/router';
-import { UserComponent } from './user.component';
-import { SharedComponentsModule } from '../shared/components/shared-components.module';
-
-
-const routes: Routes = [
- {
- path: ':id',
- component: UserComponent
- }
-]
-
-
-@NgModule({
- imports: [CommonModule, SharedComponentsModule, RouterModule.forChild(routes)],
- declarations: [UserComponent],
- exports: [UserComponent, RouterModule]
-})
-export class UserModule {}
diff --git a/web/src/components/ErrorMessage.test.tsx b/src/components/ErrorMessage.test.tsx
similarity index 100%
rename from web/src/components/ErrorMessage.test.tsx
rename to src/components/ErrorMessage.test.tsx
diff --git a/web/src/components/ErrorMessage.tsx b/src/components/ErrorMessage.tsx
similarity index 100%
rename from web/src/components/ErrorMessage.tsx
rename to src/components/ErrorMessage.tsx
diff --git a/web/src/components/Loader.test.tsx b/src/components/Loader.test.tsx
similarity index 100%
rename from web/src/components/Loader.test.tsx
rename to src/components/Loader.test.tsx
diff --git a/web/src/components/Loader.tsx b/src/components/Loader.tsx
similarity index 100%
rename from web/src/components/Loader.tsx
rename to src/components/Loader.tsx
diff --git a/web/src/components/errorMessage.scss b/src/components/errorMessage.scss
similarity index 100%
rename from web/src/components/errorMessage.scss
rename to src/components/errorMessage.scss
diff --git a/web/src/components/loader.scss b/src/components/loader.scss
similarity index 100%
rename from web/src/components/loader.scss
rename to src/components/loader.scss
diff --git a/web/src/context/SettingsContext.test.tsx b/src/context/SettingsContext.test.tsx
similarity index 100%
rename from web/src/context/SettingsContext.test.tsx
rename to src/context/SettingsContext.test.tsx
diff --git a/web/src/context/SettingsContext.tsx b/src/context/SettingsContext.tsx
similarity index 100%
rename from web/src/context/SettingsContext.tsx
rename to src/context/SettingsContext.tsx
diff --git a/web/src/core/Footer.test.tsx b/src/core/Footer.test.tsx
similarity index 100%
rename from web/src/core/Footer.test.tsx
rename to src/core/Footer.test.tsx
diff --git a/web/src/core/Footer.tsx b/src/core/Footer.tsx
similarity index 100%
rename from web/src/core/Footer.tsx
rename to src/core/Footer.tsx
diff --git a/web/src/core/Header.test.tsx b/src/core/Header.test.tsx
similarity index 100%
rename from web/src/core/Header.test.tsx
rename to src/core/Header.test.tsx
diff --git a/web/src/core/Header.tsx b/src/core/Header.tsx
similarity index 100%
rename from web/src/core/Header.tsx
rename to src/core/Header.tsx
diff --git a/web/src/core/Settings.test.tsx b/src/core/Settings.test.tsx
similarity index 92%
rename from web/src/core/Settings.test.tsx
rename to src/core/Settings.test.tsx
index 786c9223..fb01751b 100644
--- a/web/src/core/Settings.test.tsx
+++ b/src/core/Settings.test.tsx
@@ -138,4 +138,15 @@ describe('Settings', () => {
expect(screen.getByLabelText('List spacing:')).toHaveValue(3);
expect(screen.getByRole('checkbox')).toBeChecked();
});
+
+ it('switches back to the default theme', async () => {
+ localStorage.setItem('theme', 'night');
+
+ renderSettings();
+ await openSettings();
+ await userEvent.click(screen.getByRole('radio', { name: 'Default' }));
+
+ expect(screen.getByText('theme: default')).toBeInTheDocument();
+ expect(localStorage.getItem('theme')).toBe('default');
+ });
});
diff --git a/web/src/core/Settings.tsx b/src/core/Settings.tsx
similarity index 100%
rename from web/src/core/Settings.tsx
rename to src/core/Settings.tsx
diff --git a/web/src/core/footer.scss b/src/core/footer.scss
similarity index 100%
rename from web/src/core/footer.scss
rename to src/core/footer.scss
diff --git a/web/src/core/header.scss b/src/core/header.scss
similarity index 100%
rename from web/src/core/header.scss
rename to src/core/header.scss
diff --git a/web/src/core/settings.scss b/src/core/settings.scss
similarity index 100%
rename from web/src/core/settings.scss
rename to src/core/settings.scss
diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts
deleted file mode 100644
index 3612073b..00000000
--- a/src/environments/environment.prod.ts
+++ /dev/null
@@ -1,3 +0,0 @@
-export const environment = {
- production: true
-};
diff --git a/src/environments/environment.ts b/src/environments/environment.ts
deleted file mode 100644
index 7b4f817a..00000000
--- a/src/environments/environment.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-// This file can be replaced during build by using the `fileReplacements` array.
-// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
-// The list of file replacements can be found in `angular.json`.
-
-export const environment = {
- production: false
-};
-
-/*
- * For easier debugging in development mode, you can import the following file
- * to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
- *
- * This import should be commented out in production mode because it will have a negative impact
- * on performance if an error is thrown.
- */
-// import 'zone.js/dist/zone-error'; // Included with Angular CLI.
diff --git a/web/src/feeds/Item.test.tsx b/src/feeds/Item.test.tsx
similarity index 100%
rename from web/src/feeds/Item.test.tsx
rename to src/feeds/Item.test.tsx
diff --git a/web/src/feeds/Item.tsx b/src/feeds/Item.tsx
similarity index 100%
rename from web/src/feeds/Item.tsx
rename to src/feeds/Item.tsx
diff --git a/web/src/feeds/feed.scss b/src/feeds/feed.scss
similarity index 100%
rename from web/src/feeds/feed.scss
rename to src/feeds/feed.scss
diff --git a/web/src/feeds/item.scss b/src/feeds/item.scss
similarity index 100%
rename from web/src/feeds/item.scss
rename to src/feeds/item.scss
diff --git a/src/index.html b/src/index.html
deleted file mode 100644
index 05e798ea..00000000
--- a/src/index.html
+++ /dev/null
@@ -1,78 +0,0 @@
-
-
-
-
- Angular 2 HN
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Sorry, JavaScript needs to be enabled in order to run this application.
-
-
-
-
-
-
-
diff --git a/web/src/item-details/Comment.test.tsx b/src/item-details/Comment.test.tsx
similarity index 100%
rename from web/src/item-details/Comment.test.tsx
rename to src/item-details/Comment.test.tsx
diff --git a/web/src/item-details/Comment.tsx b/src/item-details/Comment.tsx
similarity index 100%
rename from web/src/item-details/Comment.tsx
rename to src/item-details/Comment.tsx
diff --git a/web/src/item-details/comment.scss b/src/item-details/comment.scss
similarity index 100%
rename from web/src/item-details/comment.scss
rename to src/item-details/comment.scss
diff --git a/web/src/item-details/itemDetails.scss b/src/item-details/itemDetails.scss
similarity index 100%
rename from web/src/item-details/itemDetails.scss
rename to src/item-details/itemDetails.scss
diff --git a/src/main.ts b/src/main.ts
deleted file mode 100644
index c7b673cf..00000000
--- a/src/main.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { enableProdMode } from '@angular/core';
-import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
-
-import { AppModule } from './app/app.module';
-import { environment } from './environments/environment';
-
-if (environment.production) {
- enableProdMode();
-}
-
-platformBrowserDynamic().bootstrapModule(AppModule)
- .catch(err => console.error(err));
diff --git a/web/src/main.tsx b/src/main.tsx
similarity index 100%
rename from web/src/main.tsx
rename to src/main.tsx
diff --git a/src/manifest.json b/src/manifest.json
deleted file mode 100644
index 88c7a8a3..00000000
--- a/src/manifest.json
+++ /dev/null
@@ -1,30 +0,0 @@
-{
- "name": "Angular 2 HN",
- "short_name": "Angular 2 HN",
- "icons": [{
- "src": "assets/icons/android-chrome-144x144.png",
- "sizes": "144x144",
- "type": "image\/png"
- },
- {
- "src": "assets/icons/android-chrome-192x192.png",
- "sizes": "192x192",
- "type": "image\/png"
- },
- {
- "src": "assets/icons/android-chrome-256x256.png",
- "sizes": "256x256",
- "type": "image\/png"
- },
- {
- "src": "assets/icons/android-chrome-512x512.png",
- "sizes": "512x512",
- "type": "image\/png"
- }
- ],
- "theme_color": "#b92b27",
- "background_color": "#ffffff",
- "display": "standalone",
- "orientation": "portrait",
- "start_url": "./?utm_source=web_app_manifest"
-}
\ No newline at end of file
diff --git a/web/src/models/comment.ts b/src/models/comment.ts
similarity index 100%
rename from web/src/models/comment.ts
rename to src/models/comment.ts
diff --git a/src/app/shared/models/feed-type.type.ts b/src/models/feed-type.type.ts
similarity index 100%
rename from src/app/shared/models/feed-type.type.ts
rename to src/models/feed-type.type.ts
diff --git a/web/src/models/index.ts b/src/models/index.ts
similarity index 100%
rename from web/src/models/index.ts
rename to src/models/index.ts
diff --git a/web/src/models/poll-result.ts b/src/models/poll-result.ts
similarity index 100%
rename from web/src/models/poll-result.ts
rename to src/models/poll-result.ts
diff --git a/web/src/models/settings.ts b/src/models/settings.ts
similarity index 100%
rename from web/src/models/settings.ts
rename to src/models/settings.ts
diff --git a/web/src/models/story.ts b/src/models/story.ts
similarity index 100%
rename from web/src/models/story.ts
rename to src/models/story.ts
diff --git a/web/src/models/user.ts b/src/models/user.ts
similarity index 100%
rename from web/src/models/user.ts
rename to src/models/user.ts
diff --git a/web/src/pages/FeedPage.test.tsx b/src/pages/FeedPage.test.tsx
similarity index 91%
rename from web/src/pages/FeedPage.test.tsx
rename to src/pages/FeedPage.test.tsx
index fe3077b1..cbbea308 100644
--- a/web/src/pages/FeedPage.test.tsx
+++ b/src/pages/FeedPage.test.tsx
@@ -171,4 +171,21 @@ describe('FeedPage', () => {
expect(fetchFeedMock).toHaveBeenLastCalledWith('news', 2);
expect(screen.queryByText('Story 1')).toBeNull();
});
+
+ it('ignores a feed that resolves after the page unmounted', async () => {
+ const scrollTo = vi.spyOn(window, 'scrollTo').mockImplementation(() => {});
+ let resolveFeed: (stories: Story[]) => void = () => {};
+ fetchFeedMock.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveFeed = resolve;
+ })
+ );
+
+ const { unmount } = renderFeed();
+ unmount();
+ resolveFeed(makeStories(3));
+
+ await waitFor(() => expect(scrollTo).not.toHaveBeenCalled());
+ expect(screen.queryByText('Story 1')).toBeNull();
+ });
});
diff --git a/web/src/pages/FeedPage.tsx b/src/pages/FeedPage.tsx
similarity index 100%
rename from web/src/pages/FeedPage.tsx
rename to src/pages/FeedPage.tsx
diff --git a/web/src/pages/ItemDetailsPage.test.tsx b/src/pages/ItemDetailsPage.test.tsx
similarity index 94%
rename from web/src/pages/ItemDetailsPage.test.tsx
rename to src/pages/ItemDetailsPage.test.tsx
index 272f7be8..17ba7825 100644
--- a/web/src/pages/ItemDetailsPage.test.tsx
+++ b/src/pages/ItemDetailsPage.test.tsx
@@ -264,4 +264,21 @@ describe('ItemDetailsPage', () => {
await screen.findAllByText('Another story');
expect(fetchItemContentMock).toHaveBeenLastCalledWith(7);
});
+
+ it('renders poll options when the aggregated vote count is missing', async () => {
+ fetchItemContentMock.mockResolvedValue(
+ buildStory({
+ type: 'poll',
+ poll: [{ content: 'Option A', points: 30 }],
+ poll_votes_count: undefined,
+ })
+ );
+
+ const { container } = renderPage();
+ await screen.findAllByText('A React story');
+
+ const pollContent = container.querySelector('.pollResults .pollContent');
+ expect(pollContent?.textContent).toContain('Option A');
+ expect(pollContent?.querySelector('.pollBar')).not.toBeNull();
+ });
});
diff --git a/web/src/pages/ItemDetailsPage.tsx b/src/pages/ItemDetailsPage.tsx
similarity index 100%
rename from web/src/pages/ItemDetailsPage.tsx
rename to src/pages/ItemDetailsPage.tsx
diff --git a/web/src/pages/UserPage.tsx b/src/pages/UserPage.tsx
similarity index 100%
rename from web/src/pages/UserPage.tsx
rename to src/pages/UserPage.tsx
diff --git a/src/polyfills.ts b/src/polyfills.ts
deleted file mode 100644
index aa665d6b..00000000
--- a/src/polyfills.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-/**
- * This file includes polyfills needed by Angular and is loaded before the app.
- * You can add your own extra polyfills to this file.
- *
- * This file is divided into 2 sections:
- * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
- * 2. Application imports. Files imported after ZoneJS that should be loaded before your main
- * file.
- *
- * The current setup is for so-called "evergreen" browsers; the last versions of browsers that
- * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
- * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
- *
- * Learn more in https://angular.io/guide/browser-support
- */
-
-/***************************************************************************************************
- * BROWSER POLYFILLS
- */
-
-/** IE10 and IE11 requires the following for NgClass support on SVG elements */
-// import 'classlist.js'; // Run `npm install --save classlist.js`.
-
-/**
- * Web Animations `@angular/platform-browser/animations`
- * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
- * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
- */
-// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
-
-/**
- * By default, zone.js will patch all possible macroTask and DomEvents
- * user can disable parts of macroTask/DomEvents patch by setting following flags
- * because those flags need to be set before `zone.js` being loaded, and webpack
- * will put import in the top of bundle, so user need to create a separate file
- * in this directory (for example: zone-flags.ts), and put the following flags
- * into that file, and then add the following code before importing zone.js.
- * import './zone-flags.ts';
- *
- * The flags allowed in zone-flags.ts are listed here.
- *
- * The following flags will work for all browsers.
- *
- * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
- * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
- * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
- *
- * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
- * with the following flag, it will bypass `zone.js` patch for IE/Edge
- *
- * (window as any).__Zone_enable_cross_context_check = true;
- *
- */
-
-/***************************************************************************************************
- * Zone JS is required by default for Angular itself.
- */
-import 'zone.js/dist/zone'; // Included with Angular CLI.
-
-
-/***************************************************************************************************
- * APPLICATION IMPORTS
- */
diff --git a/web/src/routes.test.tsx b/src/routes.test.tsx
similarity index 100%
rename from web/src/routes.test.tsx
rename to src/routes.test.tsx
diff --git a/web/src/routes.tsx b/src/routes.tsx
similarity index 100%
rename from web/src/routes.tsx
rename to src/routes.tsx
diff --git a/web/src/setupTests.ts b/src/setupTests.ts
similarity index 100%
rename from web/src/setupTests.ts
rename to src/setupTests.ts
diff --git a/src/styles.scss b/src/styles.scss
deleted file mode 100644
index 3e06b937..00000000
--- a/src/styles.scss
+++ /dev/null
@@ -1,43 +0,0 @@
-@import "./app/shared/scss/themes";
-
-html {
- height: 100%;
- width: 100%;
-}
-
-body {
- margin: 0;
- height: 100%;
- width: 100%;
-}
-
-pre {
- white-space: pre-wrap;
-}
-
-.app-loader {
- display: flex;
- align-items: center;
- justify-content: center;
- opacity: 0;
- position: fixed;
- height: 100%;
- width: 100%;
- top: 0;
- left: 0;
- z-index: -1;
-}
-
-@media screen and (max-width: 768px) {
- .app-loader {
- background-color: #fff;
- }
-}
-
-app-root:empty + .app-loader {
- opacity: 1;
- z-index: 100;
-}
-app-root:empty + .app-loader .logo {
- width: 20vh;
-}
diff --git a/web/src/styles/_media.scss b/src/styles/_media.scss
similarity index 100%
rename from web/src/styles/_media.scss
rename to src/styles/_media.scss
diff --git a/web/src/styles/_theme_variables.scss b/src/styles/_theme_variables.scss
similarity index 100%
rename from web/src/styles/_theme_variables.scss
rename to src/styles/_theme_variables.scss
diff --git a/web/src/styles/_themes.scss b/src/styles/_themes.scss
similarity index 100%
rename from web/src/styles/_themes.scss
rename to src/styles/_themes.scss
diff --git a/web/src/styles/global.scss b/src/styles/global.scss
similarity index 100%
rename from web/src/styles/global.scss
rename to src/styles/global.scss
diff --git a/src/test.ts b/src/test.ts
deleted file mode 100644
index 16317897..00000000
--- a/src/test.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-// This file is required by karma.conf.js and loads recursively all the .spec and framework files
-
-import 'zone.js/dist/zone-testing';
-import { getTestBed } from '@angular/core/testing';
-import {
- BrowserDynamicTestingModule,
- platformBrowserDynamicTesting
-} from '@angular/platform-browser-dynamic/testing';
-
-declare const require: any;
-
-// First, initialize the Angular testing environment.
-getTestBed().initTestEnvironment(
- BrowserDynamicTestingModule,
- platformBrowserDynamicTesting()
-);
-// Then we find all the tests.
-const context = require.context('./', true, /\.spec\.ts$/);
-// And load the modules.
-context.keys().map(context);
diff --git a/web/src/testUtils/matchMedia.ts b/src/testUtils/matchMedia.ts
similarity index 100%
rename from web/src/testUtils/matchMedia.ts
rename to src/testUtils/matchMedia.ts
diff --git a/web/src/user/UserPage.test.tsx b/src/user/UserPage.test.tsx
similarity index 87%
rename from web/src/user/UserPage.test.tsx
rename to src/user/UserPage.test.tsx
index 8856fe54..e8bce8c1 100644
--- a/web/src/user/UserPage.test.tsx
+++ b/src/user/UserPage.test.tsx
@@ -102,4 +102,19 @@ describe('UserPage', () => {
await waitFor(() => expect(screen.getByText('news feed')).toBeInTheDocument());
});
+
+ it('does not fetch anything when the route carries no user id', () => {
+ const { container } = render(
+
+
+
+ } />
+
+
+
+ );
+
+ expect(fetchUserMock).not.toHaveBeenCalled();
+ expect(container.querySelector('.loading-section')).not.toBeNull();
+ });
});
diff --git a/web/src/user/user.scss b/src/user/user.scss
similarity index 100%
rename from web/src/user/user.scss
rename to src/user/user.scss
diff --git a/web/src/utils/formatCommentCount.test.ts b/src/utils/formatCommentCount.test.ts
similarity index 100%
rename from web/src/utils/formatCommentCount.test.ts
rename to src/utils/formatCommentCount.test.ts
diff --git a/web/src/utils/formatCommentCount.ts b/src/utils/formatCommentCount.ts
similarity index 100%
rename from web/src/utils/formatCommentCount.ts
rename to src/utils/formatCommentCount.ts
diff --git a/web/src/vite-env.d.ts b/src/vite-env.d.ts
similarity index 100%
rename from web/src/vite-env.d.ts
rename to src/vite-env.d.ts
diff --git a/tsconfig.app.json b/tsconfig.app.json
deleted file mode 100644
index f758d982..00000000
--- a/tsconfig.app.json
+++ /dev/null
@@ -1,14 +0,0 @@
-{
- "extends": "./tsconfig.json",
- "compilerOptions": {
- "outDir": "./out-tsc/app",
- "types": []
- },
- "files": [
- "src/main.ts",
- "src/polyfills.ts"
- ],
- "include": [
- "src/**/*.d.ts"
- ]
-}
diff --git a/tsconfig.json b/tsconfig.json
index 6ec9ceb1..9e6e3441 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,22 +1,21 @@
{
- "compileOnSave": false,
- "compilerOptions": {
- "baseUrl": "./",
- "outDir": "./dist/out-tsc",
- "sourceMap": true,
- "declaration": false,
- "module": "esnext",
- "moduleResolution": "node",
- "emitDecoratorMetadata": true,
- "experimentalDecorators": true,
- "importHelpers": true,
- "target": "es2015",
- "typeRoots": [
- "node_modules/@types"
- ],
- "lib": [
- "es2018",
- "dom"
- ]
- }
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "types": ["node", "vitest/globals", "@testing-library/jest-dom"]
+ },
+ "include": ["src", "e2e", "vite.config.ts", "playwright.config.ts"]
}
diff --git a/tsconfig.spec.json b/tsconfig.spec.json
deleted file mode 100644
index 6400fde7..00000000
--- a/tsconfig.spec.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "extends": "./tsconfig.json",
- "compilerOptions": {
- "outDir": "./out-tsc/spec",
- "types": [
- "jasmine",
- "node"
- ]
- },
- "files": [
- "src/test.ts",
- "src/polyfills.ts"
- ],
- "include": [
- "src/**/*.spec.ts",
- "src/**/*.d.ts"
- ]
-}
diff --git a/tslint.json b/tslint.json
deleted file mode 100644
index 188bd78d..00000000
--- a/tslint.json
+++ /dev/null
@@ -1,92 +0,0 @@
-{
- "extends": "tslint:recommended",
- "rules": {
- "array-type": false,
- "arrow-parens": false,
- "deprecation": {
- "severity": "warn"
- },
- "component-class-suffix": true,
- "contextual-lifecycle": true,
- "directive-class-suffix": true,
- "directive-selector": [
- true,
- "attribute",
- "app",
- "camelCase"
- ],
- "component-selector": [
- true,
- "element",
- "app",
- "kebab-case"
- ],
- "import-blacklist": [
- true,
- "rxjs/Rx"
- ],
- "interface-name": false,
- "max-classes-per-file": false,
- "max-line-length": [
- true,
- 140
- ],
- "member-access": false,
- "member-ordering": [
- true,
- {
- "order": [
- "static-field",
- "instance-field",
- "static-method",
- "instance-method"
- ]
- }
- ],
- "no-consecutive-blank-lines": false,
- "no-console": [
- true,
- "debug",
- "info",
- "time",
- "timeEnd",
- "trace"
- ],
- "no-empty": false,
- "no-inferrable-types": [
- true,
- "ignore-params"
- ],
- "no-non-null-assertion": true,
- "no-redundant-jsdoc": true,
- "no-switch-case-fall-through": true,
- "no-use-before-declare": true,
- "no-var-requires": false,
- "object-literal-key-quotes": [
- true,
- "as-needed"
- ],
- "object-literal-sort-keys": false,
- "ordered-imports": false,
- "quotemark": [
- true,
- "single"
- ],
- "trailing-comma": false,
- "no-conflicting-lifecycle": true,
- "no-host-metadata-property": true,
- "no-input-rename": true,
- "no-inputs-metadata-property": true,
- "no-output-native": true,
- "no-output-on-prefix": true,
- "no-output-rename": true,
- "no-outputs-metadata-property": true,
- "template-banana-in-box": true,
- "template-no-negated-async": true,
- "use-lifecycle-interface": true,
- "use-pipe-transform-interface": true
- },
- "rulesDirectory": [
- "codelyzer"
- ]
-}
\ No newline at end of file
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 00000000..fc95b487
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,66 @@
+///
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import { VitePWA } from 'vite-plugin-pwa';
+
+export default defineConfig({
+ plugins: [
+ react(),
+ VitePWA({
+ registerType: 'autoUpdate',
+ includeAssets: ['favicon.ico', 'assets/icons/**/*', 'assets/images/**/*'],
+ manifest: {
+ name: 'Angular 2 HN',
+ short_name: 'Angular 2 HN',
+ icons: [
+ { src: 'assets/icons/android-chrome-144x144.png', sizes: '144x144', type: 'image/png' },
+ { src: 'assets/icons/android-chrome-192x192.png', sizes: '192x192', type: 'image/png' },
+ { src: 'assets/icons/android-chrome-256x256.png', sizes: '256x256', type: 'image/png' },
+ { src: 'assets/icons/android-chrome-512x512.png', sizes: '512x512', type: 'image/png' },
+ ],
+ theme_color: '#b92b27',
+ background_color: '#ffffff',
+ display: 'standalone',
+ orientation: 'portrait',
+ start_url: './?utm_source=web_app_manifest',
+ },
+ workbox: {
+ // App Shell: every navigation falls back to the precached index.html.
+ navigateFallback: 'index.html',
+ globPatterns: ['**/*.{js,css,html,ico,png,svg,xml}'],
+ runtimeCaching: [
+ {
+ urlPattern: /^https:\/\/node-hnapi\.herokuapp\.com\/.*$/,
+ handler: 'NetworkFirst',
+ options: {
+ cacheName: 'hn-api',
+ networkTimeoutSeconds: 10,
+ expiration: { maxEntries: 100, maxAgeSeconds: 60 * 60 * 24 },
+ cacheableResponse: { statuses: [0, 200] },
+ },
+ },
+ ],
+ },
+ }),
+ ],
+ test: {
+ globals: true,
+ environment: 'jsdom',
+ setupFiles: './src/setupTests.ts',
+ css: false,
+ exclude: ['e2e/**', 'node_modules/**', 'dist/**'],
+ coverage: {
+ provider: 'v8',
+ reporter: ['text', 'lcov'],
+ include: ['src/**/*.{ts,tsx}'],
+ exclude: [
+ 'src/**/*.test.{ts,tsx}',
+ 'src/main.tsx',
+ 'src/setupTests.ts',
+ 'src/vite-env.d.ts',
+ 'src/testUtils/**',
+ 'src/models/**',
+ ],
+ },
+ },
+});
diff --git a/web/.gitignore b/web/.gitignore
deleted file mode 100644
index d4e4218d..00000000
--- a/web/.gitignore
+++ /dev/null
@@ -1,5 +0,0 @@
-node_modules
-dist
-dev-dist
-coverage
-*.local
diff --git a/web/index.html b/web/index.html
deleted file mode 100644
index 83e387cf..00000000
--- a/web/index.html
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
-
- Angular 2 HN
-
-
-
-
-
-
-
-
-
-
-
diff --git a/web/package.json b/web/package.json
deleted file mode 100644
index a3902130..00000000
--- a/web/package.json
+++ /dev/null
@@ -1,52 +0,0 @@
-{
- "name": "react-hnpwa",
- "version": "0.0.0",
- "private": true,
- "type": "module",
- "scripts": {
- "dev": "vite",
- "start": "vite",
- "build": "tsc --noEmit && vite build",
- "preview": "vite preview",
- "lint": "eslint .",
- "format": "prettier --write \"src/**/*.{ts,tsx,scss}\"",
- "test": "vitest run",
- "test:watch": "vitest",
- "test:coverage": "vitest run --coverage"
- },
- "dependencies": {
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
- "react-router-dom": "^6.26.2"
- },
- "devDependencies": {
- "@eslint/js": "^9.11.1",
- "@testing-library/dom": "^10.4.0",
- "@testing-library/jest-dom": "^6.5.0",
- "@testing-library/react": "^16.0.1",
- "@testing-library/user-event": "^14.5.2",
- "@types/react": "^18.3.10",
- "@types/react-dom": "^18.3.0",
- "@vitejs/plugin-react": "^4.3.1",
- "@vitest/coverage-v8": "^2.1.1",
- "eslint": "^9.11.1",
- "eslint-config-prettier": "^9.1.0",
- "eslint-plugin-react": "^7.37.0",
- "eslint-plugin-react-hooks": "^5.0.0",
- "eslint-plugin-react-refresh": "^0.4.12",
- "globals": "^15.9.0",
- "jsdom": "^25.0.1",
- "prettier": "^3.3.3",
- "sass": "~1.77.8",
- "typescript": "^5.5.4",
- "typescript-eslint": "^8.7.0",
- "vite": "^5.4.8",
- "vitest": "^2.1.1"
- },
- "prettier": {
- "trailingComma": "es5",
- "tabWidth": 4,
- "singleQuote": true,
- "printWidth": 120
- }
-}
diff --git a/web/public/assets/icons/android-chrome-144x144.png b/web/public/assets/icons/android-chrome-144x144.png
deleted file mode 100644
index 833fcf97ffa8be01d9efa488b6a927f62fb0ebf2..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 29992
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 mg~B#bqT^yv
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
diff --git a/web/public/assets/icons/android-chrome-512x512.png b/web/public/assets/icons/android-chrome-512x512.png
deleted file mode 100644
index 5baddb33011b10828e9539a0a7b662993b8b3928..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 30053
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
--
-
-
-
-