diff --git a/package.json b/package.json
index 1ccfd7e..cf1d01f 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"test:unit:watch": "vitest"
},
"devDependencies": {
+ "@iconify-json/mdi": "^1.2.0",
"@playwright/test": "^1.28.1",
"@skeletonlabs/skeleton": "2.10.2",
"@skeletonlabs/tw-plugin": "0.4.0",
@@ -42,6 +43,7 @@
"tailwindcss": "3.4.12",
"typescript": "^5.0.0",
"typescript-eslint": "^8.0.0",
+ "unplugin-icons": "^0.19.3",
"vite": "^5.4.8",
"vite-plugin-tailwind-purgecss": "0.3.3",
"vitest": "^2.0.0"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7500c2f..13a3572 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -12,6 +12,9 @@ importers:
specifier: ^10.13.2
version: 10.13.2
devDependencies:
+ '@iconify-json/mdi':
+ specifier: ^1.2.0
+ version: 1.2.0
'@playwright/test':
specifier: ^1.28.1
version: 1.47.2
@@ -81,6 +84,9 @@ importers:
typescript-eslint:
specifier: ^8.0.0
version: 8.6.0(eslint@9.11.0(jiti@1.21.6))(typescript@5.6.2)
+ unplugin-icons:
+ specifier: ^0.19.3
+ version: 0.19.3
vite:
specifier: ^5.4.8
version: 5.4.8(@types/node@22.5.5)
@@ -101,6 +107,12 @@ packages:
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'}
+ '@antfu/install-pkg@0.4.1':
+ resolution: {integrity: sha512-T7yB5QNG29afhWVkVq7XeIMBa5U/vs9mX69YqayXypPRmYzUmzwnYltplHmPtZ4HPCn+sQKeXW8I47wCbuBOjw==}
+
+ '@antfu/utils@0.7.10':
+ resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==}
+
'@babel/helper-string-parser@7.24.8':
resolution: {integrity: sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==}
engines: {node: '>=6.9.0'}
@@ -495,6 +507,15 @@ packages:
resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==}
engines: {node: '>=18.18'}
+ '@iconify-json/mdi@1.2.0':
+ resolution: {integrity: sha512-E9/3l5Syg3wfuarorFodhn4s8YorxhH3U3U20LaNBNiqw1kFNIDWhF6HymuzAD35k7RH0OBasJ+ZUyFtVVV6eg==}
+
+ '@iconify/types@2.0.0':
+ resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==}
+
+ '@iconify/utils@2.1.33':
+ resolution: {integrity: sha512-jP9h6v/g0BIZx0p7XGJJVtkVnydtbgTgt9mVNcGDYwaa7UhdHdI9dvoq+gKj9sijMSJKxUPEG2JyjsgXjxL7Kw==}
+
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@@ -960,6 +981,9 @@ packages:
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
+ confbox@0.1.7:
+ resolution: {integrity: sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==}
+
cookie@0.6.0:
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
engines: {node: '>= 0.6'}
@@ -1338,6 +1362,9 @@ packages:
known-css-properties@0.34.0:
resolution: {integrity: sha512-tBECoUqNFbyAY4RrbqsBQqDFpGXAEbdD5QKr8kACx3+rnArmuuR22nKQWKazvp07N9yjTyDZaw/20UIH8tL9DQ==}
+ kolorist@1.8.0:
+ resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==}
+
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
@@ -1353,6 +1380,10 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+ local-pkg@0.5.0:
+ resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==}
+ engines: {node: '>=14'}
+
locate-character@3.0.0:
resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==}
@@ -1407,6 +1438,9 @@ packages:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
+ mlly@1.7.1:
+ resolution: {integrity: sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==}
+
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
@@ -1463,6 +1497,9 @@ packages:
package-json-from-dist@1.0.0:
resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==}
+ package-manager-detector@0.2.0:
+ resolution: {integrity: sha512-E385OSk9qDcXhcM9LNSe4sdhx8a9mAPrZ4sMLW+tmxl5ZuGtPUcdFu+MPP2jbgiWAZ6Pfe5soGFMd+0Db5Vrog==}
+
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -1513,6 +1550,9 @@ packages:
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
engines: {node: '>= 6'}
+ pkg-types@1.2.0:
+ resolution: {integrity: sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==}
+
playwright-core@1.47.2:
resolution: {integrity: sha512-3JvMfF+9LJfe16l7AbSmU555PaTl2tPyQsVInqm3id16pdDfvZ8TTZ/pyzmkbDrZTQefyzU7AIHlZqQnxpqHVQ==}
engines: {node: '>=18'}
@@ -1842,6 +1882,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
+ ufo@1.5.4:
+ resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==}
+
undici-types@6.19.8:
resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
@@ -1849,6 +1892,35 @@ packages:
resolution: {integrity: sha512-HR3W/bMGPSr90i8AAp2C4DM3wChFdJPLrWYpIS++LxS8K+W535qftjt+4MyjNYHeWabMj1nvtmLIi7l++iq91A==}
engines: {node: '>=18.17'}
+ unplugin-icons@0.19.3:
+ resolution: {integrity: sha512-EUegRmsAI6+rrYr0vXjFlIP+lg4fSC4zb62zAZKx8FGXlWAGgEGBCa3JDe27aRAXhistObLPbBPhwa/0jYLFkQ==}
+ peerDependencies:
+ '@svgr/core': '>=7.0.0'
+ '@svgx/core': ^1.0.1
+ '@vue/compiler-sfc': ^3.0.2 || ^2.7.0
+ vue-template-compiler: ^2.6.12
+ vue-template-es2015-compiler: ^1.9.0
+ peerDependenciesMeta:
+ '@svgr/core':
+ optional: true
+ '@svgx/core':
+ optional: true
+ '@vue/compiler-sfc':
+ optional: true
+ vue-template-compiler:
+ optional: true
+ vue-template-es2015-compiler:
+ optional: true
+
+ unplugin@1.14.1:
+ resolution: {integrity: sha512-lBlHbfSFPToDYp9pjXlUEFVxYLaue9f9T1HC+4OHlmj+HnMDdz9oZY+erXfoCe/5V/7gKUSY2jpXPb9S7f0f/w==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ webpack-sources: ^3
+ peerDependenciesMeta:
+ webpack-sources:
+ optional: true
+
update-browserslist-db@1.1.0:
resolution: {integrity: sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==}
hasBin: true
@@ -1936,6 +2008,9 @@ packages:
jsdom:
optional: true
+ webpack-virtual-modules@0.6.2:
+ resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==}
+
websocket-driver@0.7.4:
resolution: {integrity: sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==}
engines: {node: '>=0.8.0'}
@@ -2000,6 +2075,13 @@ snapshots:
'@jridgewell/gen-mapping': 0.3.5
'@jridgewell/trace-mapping': 0.3.25
+ '@antfu/install-pkg@0.4.1':
+ dependencies:
+ package-manager-detector: 0.2.0
+ tinyexec: 0.3.0
+
+ '@antfu/utils@0.7.10': {}
+
'@babel/helper-string-parser@7.24.8': {}
'@babel/helper-validator-identifier@7.24.7': {}
@@ -2451,6 +2533,24 @@ snapshots:
'@humanwhocodes/retry@0.3.0': {}
+ '@iconify-json/mdi@1.2.0':
+ dependencies:
+ '@iconify/types': 2.0.0
+
+ '@iconify/types@2.0.0': {}
+
+ '@iconify/utils@2.1.33':
+ dependencies:
+ '@antfu/install-pkg': 0.4.1
+ '@antfu/utils': 0.7.10
+ '@iconify/types': 2.0.0
+ debug: 4.3.7
+ kolorist: 1.8.0
+ local-pkg: 0.5.0
+ mlly: 1.7.1
+ transitivePeerDependencies:
+ - supports-color
+
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@@ -2927,6 +3027,8 @@ snapshots:
concat-map@0.0.1: {}
+ confbox@0.1.7: {}
+
cookie@0.6.0: {}
cross-spawn@7.0.3:
@@ -3335,6 +3437,8 @@ snapshots:
known-css-properties@0.34.0: {}
+ kolorist@1.8.0: {}
+
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
@@ -3346,6 +3450,11 @@ snapshots:
lines-and-columns@1.2.4: {}
+ local-pkg@0.5.0:
+ dependencies:
+ mlly: 1.7.1
+ pkg-types: 1.2.0
+
locate-character@3.0.0: {}
locate-path@6.0.0:
@@ -3397,6 +3506,13 @@ snapshots:
minipass@7.1.2: {}
+ mlly@1.7.1:
+ dependencies:
+ acorn: 8.12.1
+ pathe: 1.1.2
+ pkg-types: 1.2.0
+ ufo: 1.5.4
+
mri@1.2.0: {}
mrmime@2.0.0: {}
@@ -3442,6 +3558,8 @@ snapshots:
package-json-from-dist@1.0.0: {}
+ package-manager-detector@0.2.0: {}
+
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -3484,6 +3602,12 @@ snapshots:
pirates@4.0.6: {}
+ pkg-types@1.2.0:
+ dependencies:
+ confbox: 0.1.7
+ mlly: 1.7.1
+ pathe: 1.1.2
+
playwright-core@1.47.2: {}
playwright@1.47.2:
@@ -3836,10 +3960,30 @@ snapshots:
typescript@5.6.2: {}
+ ufo@1.5.4: {}
+
undici-types@6.19.8: {}
undici@6.19.7: {}
+ unplugin-icons@0.19.3:
+ dependencies:
+ '@antfu/install-pkg': 0.4.1
+ '@antfu/utils': 0.7.10
+ '@iconify/utils': 2.1.33
+ debug: 4.3.7
+ kolorist: 1.8.0
+ local-pkg: 0.5.0
+ unplugin: 1.14.1
+ transitivePeerDependencies:
+ - supports-color
+ - webpack-sources
+
+ unplugin@1.14.1:
+ dependencies:
+ acorn: 8.12.1
+ webpack-virtual-modules: 0.6.2
+
update-browserslist-db@1.1.0(browserslist@4.23.3):
dependencies:
browserslist: 4.23.3
@@ -3926,6 +4070,8 @@ snapshots:
- supports-color
- terser
+ webpack-virtual-modules@0.6.2: {}
+
websocket-driver@0.7.4:
dependencies:
http-parser-js: 0.5.8
diff --git a/src/app.html b/src/app.html
index 9278217..dde3175 100644
--- a/src/app.html
+++ b/src/app.html
@@ -1,12 +1,12 @@
-
+
%sveltekit.head%
-
+
%sveltekit.body%
diff --git a/src/lib/assets/medieval-sheet-music.png b/src/lib/assets/medieval-sheet-music.png
new file mode 100644
index 0000000..58464e9
Binary files /dev/null and b/src/lib/assets/medieval-sheet-music.png differ
diff --git a/src/lib/components/auth/LoginForm.svelte b/src/lib/components/auth/LoginForm.svelte
new file mode 100644
index 0000000..cb4c89d
--- /dev/null
+++ b/src/lib/components/auth/LoginForm.svelte
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
Ars Antiqua Online
+
+ Explore the rich history of medieval music.
+
+
+
+
+
+ {#if error}
+
{error}
+ {/if}
+
+
+
+ - 📝 Access a collection of medieval manuscripts
+ - 🎶 Create and explore custom editions of historical works
+ - 📚 Compare multiple interpretations of polyphonic music
+
+
+
+
+ Your privacy is important. We use secure Google authentication.
+
+
+
+ Learn more about the project here.
+
+
+
+
+
diff --git a/src/lib/firebase/app.test.ts b/src/lib/firebase/app.test.ts
index a38dd6e..329546a 100644
--- a/src/lib/firebase/app.test.ts
+++ b/src/lib/firebase/app.test.ts
@@ -1,39 +1,43 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { App } from './app';
+import { initializeApp } from 'firebase/app';
+import { getAuth } from 'firebase/auth';
-const mockInitializeApp = vi.fn(() => ({ name: 'mocked-app' }));
-
-vi.mock('./config', () => ({
- firebaseConfig: {
- apiKey: 'mock-api-key',
- authDomain: 'mock-auth-domain',
- projectId: "mock-project-id",
- storageBucket: "mock-storage-bucket",
- messagingSenderId: "mock-messaging-sender-id",
- appId: "mock-app-id",
- measurementId: "mock-measurement-id"
- }
+vi.mock('firebase/app', () => ({
+ initializeApp: vi.fn()
}));
-vi.mock('firebase/app', () => ({
- initializeApp: mockInitializeApp
+vi.mock('firebase/auth', () => ({
+ getAuth: vi.fn()
}));
-describe('Firebase app initialization', () => {
- beforeEach(() => {
- vi.clearAllMocks();
- });
- it('initializes Firebase with the correct config', async () => {
- const { app } = await import('./app');
- expect(mockInitializeApp).toHaveBeenCalledTimes(1);
- expect(mockInitializeApp).toHaveBeenCalledWith(expect.objectContaining({
- apiKey: 'mock-api-key',
- authDomain: 'mock-auth-domain',
- projectId: "mock-project-id",
- storageBucket: "mock-storage-bucket",
- messagingSenderId: "mock-messaging-sender-id",
- appId: "mock-app-id",
- measurementId: "mock-measurement-id"
- }));
- expect(app).toEqual({ name: 'mocked-app' });
- });
-});
\ No newline at end of file
+describe('App', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Reset the singleton instance to test it properly
+ (App as any).instance = undefined;
+ });
+
+ it('should initialize Firebase app only once', () => {
+ const mockAuth = { auth: 'mockAuthInstance' };
+ (getAuth as any).mockReturnValue(mockAuth);
+
+ const app1 = App.getInstance();
+ const app2 = App.getInstance();
+
+ expect(initializeApp).toHaveBeenCalledTimes(1);
+ expect(getAuth).toHaveBeenCalledTimes(1);
+ expect(app1).toBe(app2); // Both instances should be the same (singleton pattern)
+ });
+
+ it('should return the Firebase auth instance', () => {
+ const mockAuth = { auth: 'mockAuthInstance' };
+ (getAuth as any).mockReturnValue(mockAuth);
+
+ const app = App.getInstance();
+ const authInstance = app.getAuthInstance();
+
+ expect(authInstance).toEqual(mockAuth);
+ expect(getAuth).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/lib/firebase/app.ts b/src/lib/firebase/app.ts
index 158a469..eb9be58 100644
--- a/src/lib/firebase/app.ts
+++ b/src/lib/firebase/app.ts
@@ -1,4 +1,25 @@
import { initializeApp } from 'firebase/app';
import { firebaseConfig } from './config';
+import { getAuth, type Auth } from 'firebase/auth';
-export const app = initializeApp(firebaseConfig);
\ No newline at end of file
+export class App {
+ private static instance: App;
+ private auth: Auth;
+
+ private constructor() {
+ const app = initializeApp(firebaseConfig);
+ this.auth = getAuth(app);
+ }
+
+ // Ensure App is a singleton so it initializes only once
+ public static getInstance(): App {
+ if (!App.instance) {
+ App.instance = new App();
+ }
+ return App.instance;
+ }
+
+ public getAuthInstance(): Auth {
+ return this.auth;
+ }
+}
diff --git a/src/lib/firebase/auth.test.ts b/src/lib/firebase/auth.test.ts
index 2d94649..ee624f8 100644
--- a/src/lib/firebase/auth.test.ts
+++ b/src/lib/firebase/auth.test.ts
@@ -1,14 +1,86 @@
-import { describe, it, expect, vi } from 'vitest';
-import { getAuth } from 'firebase/auth';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { FirebaseAuthModule } from './auth';
+import { signInWithPopup, signOut, onAuthStateChanged } from 'firebase/auth';
vi.mock('firebase/auth', () => ({
- getAuth: vi.fn(() => ({})),
+ GoogleAuthProvider: vi.fn(),
+ signInWithPopup: vi.fn(),
+ signOut: vi.fn(),
+ onAuthStateChanged: vi.fn()
}));
-describe('Firebase Auth Initialization', () => {
- it('should call getAuth and initialize auth', async () => {
- const { auth } = await import('./auth');
- expect(getAuth).toHaveBeenCalled();
- expect(auth).toBeDefined();
- });
+vi.mock('./app', () => ({
+ App: {
+ getInstance: vi.fn(() => ({
+ getAuthInstance: vi.fn(() => ({}))
+ }))
+ }
+}));
+
+describe('FirebaseAuthModule', () => {
+ let authModule: FirebaseAuthModule;
+
+ beforeEach(() => {
+ authModule = new FirebaseAuthModule();
+ vi.clearAllMocks(); // Reset mocks before each test
+ });
+
+ it('should sign in with Google successfully', async () => {
+ const mockUser = { uid: '123', email: 'test@example.com' };
+ const mockUserCredential = { user: mockUser };
+ (signInWithPopup as any).mockResolvedValueOnce(mockUserCredential);
+
+ const user = await authModule.signInWithGoogle();
+ expect(signInWithPopup).toHaveBeenCalledTimes(1);
+ expect(user).toEqual(mockUser);
+ });
+
+ it('should handle error during sign in with Google', async () => {
+ const mockError = new Error('Google sign-in failed');
+ (signInWithPopup as any).mockRejectedValueOnce(mockError);
+
+ await expect(authModule.signInWithGoogle()).rejects.toThrow('Google sign-in failed');
+ expect(signInWithPopup).toHaveBeenCalledTimes(1);
+ });
+
+ it('should sign out successfully', async () => {
+ (signOut as any).mockResolvedValueOnce(undefined);
+
+ await authModule.signOut();
+ expect(signOut).toHaveBeenCalledTimes(1);
+ });
+
+ it('should handle error during sign out', async () => {
+ const mockError = new Error('Sign out failed');
+ (signOut as any).mockRejectedValueOnce(mockError);
+
+ await expect(authModule.signOut()).rejects.toThrow('Sign out failed');
+ expect(signOut).toHaveBeenCalledTimes(1);
+ });
+
+ it('should return the current user', () => {
+ const mockUser = { uid: '123', email: 'test@example.com' };
+ Object.defineProperty(authModule['auth'], 'currentUser', {
+ get: () => mockUser
+ });
+
+ const currentUser = authModule.getCurrentUser();
+ expect(currentUser).toEqual(mockUser);
+ });
+
+ it('should listen to auth state changes', () => {
+ const callback = vi.fn();
+ const unsubscribe = vi.fn();
+ (onAuthStateChanged as any).mockImplementationOnce(
+ (auth: any, cb: (user: { uid: string }) => void) => {
+ cb({ uid: '123' }); // Simulate a user being passed to callback
+ return unsubscribe;
+ }
+ );
+
+ const returnedUnsubscribe = authModule.onAuthStateChanged(callback);
+ expect(onAuthStateChanged).toHaveBeenCalledTimes(1);
+ expect(callback).toHaveBeenCalledWith({ uid: '123' });
+ expect(returnedUnsubscribe).toBe(unsubscribe);
+ });
});
diff --git a/src/lib/firebase/auth.ts b/src/lib/firebase/auth.ts
index 8825ebb..944d9ab 100644
--- a/src/lib/firebase/auth.ts
+++ b/src/lib/firebase/auth.ts
@@ -1,3 +1,49 @@
-import { getAuth } from "firebase/auth";
+import {
+ GoogleAuthProvider,
+ onAuthStateChanged,
+ signInWithPopup,
+ signOut,
+ type Auth,
+ type User,
+ type UserCredential
+} from 'firebase/auth';
+import { App } from './app';
-export const auth = getAuth();
\ No newline at end of file
+export class FirebaseAuthModule {
+ private auth: Auth;
+
+ constructor() {
+ const app = App.getInstance();
+ this.auth = app.getAuthInstance();
+ }
+
+ public signInWithGoogle = async () => {
+ const provider: GoogleAuthProvider = new GoogleAuthProvider();
+ try {
+ const result: UserCredential = await signInWithPopup(this.auth, provider);
+ return result.user;
+ } catch (error) {
+ console.error('Error signing in with Google', error);
+ throw error;
+ }
+ };
+
+ public signOut = async () => {
+ try {
+ await signOut(this.auth);
+ } catch (error) {
+ console.error('Error signing out', error);
+ throw error;
+ }
+ };
+
+ public getCurrentUser = (): User | null => {
+ return this.auth.currentUser;
+ };
+
+ public onAuthStateChanged = (callback: (user: User | null) => void): (() => void) => {
+ return onAuthStateChanged(this.auth, callback);
+ };
+}
+
+export const auth: FirebaseAuthModule = new FirebaseAuthModule();
diff --git a/src/lib/firebase/index.ts b/src/lib/firebase/index.ts
index 9e54ca2..ccb20a6 100644
--- a/src/lib/firebase/index.ts
+++ b/src/lib/firebase/index.ts
@@ -1 +1 @@
-export { app } from './app';
\ No newline at end of file
+export { auth } from './auth';
\ No newline at end of file
diff --git a/src/lib/index.ts b/src/lib/index.ts
deleted file mode 100644
index c5877b7..0000000
--- a/src/lib/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export * from './firebase/app';
-export * from './firebase/auth';
-export * from './firebase/config';
-export * from './firebase/db';
-export * from './firebase/storage';
\ No newline at end of file
diff --git a/src/lib/stores/user.ts b/src/lib/stores/user.ts
new file mode 100644
index 0000000..df128d6
--- /dev/null
+++ b/src/lib/stores/user.ts
@@ -0,0 +1,24 @@
+import { writable } from 'svelte/store';
+import type { User } from 'firebase/auth';
+import { auth } from '$lib/firebase';
+
+function createUserStore() {
+ let unsubscribe: (() => void) | undefined;
+
+ const { subscribe, set, update } = writable<{ user: User | null; loading: boolean }>({
+ user: null,
+ loading: true, // Add a loading state to wait for auth to resolve
+ });
+
+ if (typeof window !== 'undefined') {
+ unsubscribe = auth.onAuthStateChanged((user) => {
+ set({ user, loading: false }); // Once Firebase resolves, update user and loading
+ });
+ }
+
+ return {
+ subscribe,
+ };
+}
+
+export const userStore = createUserStore();
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 35bc088..22a0b30 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,5 +1,56 @@
-
+
+ {#if loading}
+
+ {:else if authenticated}
+
+ {:else}
+
+ {/if}
+
+
+
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index b9dd138..17e0735 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -1,8 +1,11 @@
+
-
Hello from Firebase (Preview 3)!
+
Hello {$userStore.user?.displayName ?? "User"}!
Start by exploring:
/src/routes/+layout.svelte
- barebones layout
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 633b35c..b7c3f75 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -16,7 +16,7 @@ export default {
themes: {
preset: [
{
- name: 'skeleton',
+ name: 'hamlindigo',
enhancements: true
}
]
diff --git a/tests/test.ts b/tests/test.ts
index 9985ce1..b24a74d 100644
--- a/tests/test.ts
+++ b/tests/test.ts
@@ -1,6 +1,116 @@
-import { expect, test } from '@playwright/test';
+import { test, expect } from '@playwright/test';
+import type { User } from 'firebase/auth';
-test('home page has expected h1', async ({ page }) => {
- await page.goto('/');
- await expect(page.locator('h1')).toBeVisible();
+test.describe('Ars Antiqua Online Login Page', () => {
+ test('should render correctly and handle Google Sign-In success', async ({ page }) => {
+ // Mock the FirebaseAuthModule for successful sign-in
+ await page.addInitScript(() => {
+ class MockFirebaseAuthModule {
+ private mockUser: User | null = null;
+ public signInWithGoogle = async () => {
+ this.mockUser = { uid: '123', email: 'test@example.com' } as User;
+ return this.mockUser;
+ };
+ public getCurrentUser = () => {
+ return this.mockUser;
+ };
+ }
+ (window as any).auth = new MockFirebaseAuthModule();
+ });
+
+ // Navigate to the page
+ await page.goto('/');
+
+ // Check main heading
+ await expect(page.locator('h1')).toHaveText('Ars Antiqua Online');
+
+ // Check description
+ await expect(page.getByText('Explore the rich history of medieval music.')).toBeVisible();
+
+ // Check Google Sign-In button
+ const signInButton = page.getByRole('button', { name: 'Sign in with Google' });
+ await expect(signInButton).toBeVisible();
+
+ // Check feature list
+ const featureList = page.locator('ul');
+ await expect(featureList).toBeVisible();
+ const featureItems = await featureList.locator('li').all();
+ expect(featureItems).toHaveLength(3);
+ await expect(featureItems[0]).toContainText('Access a collection of medieval manuscripts');
+ await expect(featureItems[1]).toContainText(
+ 'Create and explore custom editions of historical works'
+ );
+ await expect(featureItems[2]).toContainText(
+ 'Compare multiple interpretations of polyphonic music'
+ );
+
+ // Check privacy notice
+ await expect(page.getByText('Your privacy is important.')).toBeVisible();
+
+ // Check "Learn more" link
+ const learnMoreLink = page.getByRole('link', { name: 'here' });
+ await expect(learnMoreLink).toHaveAttribute('href', '/about');
+
+ // Check background image
+ const backgroundImage = page.locator('.background-image');
+ await expect(backgroundImage).toBeVisible();
+ const backgroundImageStyle = await backgroundImage.evaluate(
+ (el) => window.getComputedStyle(el).backgroundImage
+ );
+ expect(backgroundImageStyle).toContain('medieval-sheet-music');
+
+ // Test successful Google Sign-In
+ const signInPromise = page.evaluate(() => (window as any).auth.signInWithGoogle());
+ await signInButton.click();
+ await signInPromise;
+
+ // Verify that the user is signed in
+ const currentUser = await page.evaluate(() => (window as any).auth.getCurrentUser());
+ expect(currentUser).toEqual(
+ expect.objectContaining({
+ uid: '123',
+ email: 'test@example.com'
+ })
+ );
+ });
+
+ test('should handle Google Sign-In error', async ({ page }) => {
+ // Mock the FirebaseAuthModule to simulate an error
+ await page.addInitScript(() => {
+ class MockFirebaseAuthModule {
+ public signInWithGoogle = async () => {
+ throw new Error('Error signing in with Google');
+ };
+
+ public getCurrentUser = () => {
+ return null;
+ };
+ }
+
+ (window as any).auth = new MockFirebaseAuthModule();
+ });
+
+ await page.goto('/');
+
+ const signInButton = page.getByRole('button', { name: 'Sign in with Google' });
+ await expect(signInButton).toBeVisible();
+
+ let errorMessage = '';
+ try {
+ await page.evaluate(() => (window as any).auth.signInWithGoogle());
+ } catch (error) {
+ errorMessage = (error as Error).message;
+ }
+
+ expect(errorMessage).toContain('Error signing in with Google');
+
+ const currentUser = await page.evaluate(() => (window as any).auth.getCurrentUser());
+ expect(currentUser).toBeNull();
+
+ // Check if the error is displayed on the page (adjust selector as needed)
+ const errorElement = page.locator('.error-message');
+ if (await errorElement.isVisible()) {
+ await expect(errorElement).toContainText('Error signing in with Google');
+ }
+ });
});
diff --git a/vite.config.ts b/vite.config.ts
index 59e7cb6..d78cfdb 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,9 +1,13 @@
import { purgeCss } from 'vite-plugin-tailwind-purgecss';
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
+import Icons from 'unplugin-icons/vite';
export default defineConfig({
- plugins: [sveltekit(), purgeCss()],
+ plugins: [sveltekit(), purgeCss(), Icons({
+ compiler: 'svelte',
+ autoInstall: true,
+ })],
test: {
include: ['src/**/*.{test,spec}.{js,ts}'],
exclude: ['node_modules', '.svelte-kit', 'static'],
@@ -20,6 +24,7 @@ export default defineConfig({
'**/types/**',
'**/*.svelte',
'**/index.ts',
+ '**/lib/stores/**', // TODO: Fix coverage for stores
],
include: ['src/**/*.ts', 'src/**/*.js'],
},