diff --git a/firebase.ts b/firebase.ts index f3c1b26..8494fec 100644 --- a/firebase.ts +++ b/firebase.ts @@ -3,6 +3,7 @@ import { initializeApp } from 'firebase/app'; import { getFirestore } from 'firebase/firestore'; import { environment } from './src/environments/environment'; import { getStorage } from 'firebase/storage'; +import { getAuth } from 'firebase/auth'; // TODO: Add SDKs for Firebase products that you want to use // https://firebase.google.com/docs/web/setup#available-libraries @@ -21,3 +22,4 @@ const firebaseConfig = { const app = initializeApp(firebaseConfig); export const db = getFirestore(app); export const storage = getStorage(app); +export const auth = getAuth(app); diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index c62ab4a..1bbfd67 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -2,7 +2,9 @@ import { Routes } from '@angular/router'; import { Story } from './story/story'; import { Home } from './home/home'; import { DisplayStory } from './components/display-story/display-story'; - +import { Signin } from './components/signin/signin'; +import { Signup } from './components/signup/signup'; +import { UserStoriesComponent } from './components/my-stories/my-stories'; export const routes: Routes = [ { @@ -17,4 +19,16 @@ export const routes: Routes = [ path: 'viewStory', component: DisplayStory, }, + { + path: 'signin', + component: Signin, + }, + { + path: 'signup', + component: Signup, + }, + { + path: 'my-stories', + component: UserStoriesComponent, + }, ]; diff --git a/src/app/components/display-story/display-story.html b/src/app/components/display-story/display-story.html index 1f4f776..3b99374 100644 --- a/src/app/components/display-story/display-story.html +++ b/src/app/components/display-story/display-story.html @@ -68,13 +68,22 @@

@if(!checkFeedbackSubmitted()){ - - +
+ + +
} } diff --git a/src/app/components/display-story/display-story.ts b/src/app/components/display-story/display-story.ts index 0bcea50..c9f2132 100644 --- a/src/app/components/display-story/display-story.ts +++ b/src/app/components/display-story/display-story.ts @@ -1,5 +1,5 @@ import { SocialShare } from '../social-share/social-share'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, Router } from '@angular/router'; import { StoryPartWithImg } from '../../model/story.type'; import { doc, getDoc } from 'firebase/firestore'; import { db } from '../../../../firebase'; @@ -32,6 +32,9 @@ import { of } from 'rxjs'; import { TestimonialDialog } from '../ui/dialog-box/testimonial-dialog'; import { generateStoryPdf } from '../../utils/pdfGenertor'; import { StoryService } from '../../services/story.service'; +import { AuthStore } from '../../services/auth.store'; +import { StorySaveService } from '../../services/save.story.service'; +import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; @Component({ selector: 'app-display-story', @@ -47,6 +50,7 @@ import { StoryService } from '../../services/story.service'; MatBadgeModule, CommonModule, SocialShare, + MatSnackBarModule, ], }) export class DisplayStory implements OnInit, OnDestroy { @@ -64,8 +68,16 @@ export class DisplayStory implements OnInit, OnDestroy { speakingSignal = signal(false); storyAudio = signal([]); storyLanguage = signal(''); + storyId = signal(''); testimonialDialog = inject(MatDialog); + readonly authStore = inject(AuthStore); + + private readonly saveStoryService = inject(StorySaveService); + private readonly snackBar = inject(MatSnackBar); + private readonly router = inject(Router); + + readonly isLoggedIn = this.authStore.isLoggedIn; // For modal content modalContent = signal<{ @@ -97,6 +109,7 @@ export class DisplayStory implements OnInit, OnDestroy { return; } }); + this.storyId.set(id); if (isPlatformServer(this.platformId)) { this.storyService.getStory(id).subscribe((storyData) => { this.isLoading.set(false); @@ -341,6 +354,30 @@ export class DisplayStory implements OnInit, OnDestroy { handleSpeech(shouldSpeak: boolean) { this.speakingSignal.set(shouldSpeak); } + async onSaveStory() { + const uid = this.authStore.currentUser?.uid; + if (uid && this.storyId()) { + const result = await this.saveStoryService.saveStory(this.storyId(), uid); + + if (result.success) { + const snackBarRef = this.snackBar.open(result.message, 'View Stories', { + duration: 5000, + horizontalPosition: 'center', + verticalPosition: 'bottom', + }); + + snackBarRef.onAction().subscribe(() => { + this.router.navigate(['/my-stories']); + }); + } else { + this.snackBar.open(result.message, 'Close', { + duration: 3000, + horizontalPosition: 'center', + verticalPosition: 'bottom', + }); + } + } + } } @Component({ diff --git a/src/app/components/my-stories/my-stories.css b/src/app/components/my-stories/my-stories.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/my-stories/my-stories.html b/src/app/components/my-stories/my-stories.html new file mode 100644 index 0000000..c4680bc --- /dev/null +++ b/src/app/components/my-stories/my-stories.html @@ -0,0 +1,68 @@ +
+

Your Stories

+ +
+ @if (!loading() && userStories().length > 0) { @for (story of userStories(); + track story.id) { +
+
+ {{ story.name }} +
+
+

{{ story.name }}

+
+ + Read Story + + +
+
+
+ } } @else if (!loading()) { +

No stories saved yet.

+ } @else { + + @for (_ of [1, 2]; track _) { +
+
+
+
+
+
+
+
+
+ } } +
+
+ + +@if (isModalOpen()) { + + + +} \ No newline at end of file diff --git a/src/app/components/my-stories/my-stories.ts b/src/app/components/my-stories/my-stories.ts new file mode 100644 index 0000000..a87aece --- /dev/null +++ b/src/app/components/my-stories/my-stories.ts @@ -0,0 +1,73 @@ +import { Component, inject, effect, signal } from '@angular/core'; +import { StorySaveService } from '../../services/save.story.service'; +import { AuthStore } from '../../services/auth.store'; +import { CommonModule } from '@angular/common'; +import { RouterLink } from '@angular/router'; + +@Component({ + selector: 'app-my-stories', + templateUrl: './my-stories.html', + styleUrls: ['./my-stories.css'], + imports: [CommonModule, RouterLink], +}) +export class UserStoriesComponent { + userStories = signal([]); + loading = signal(true); + isModalOpen = signal(false); + selectedStoryId = signal(null); + + private readonly storyService = inject(StorySaveService); + private readonly authService = inject(AuthStore); + + constructor() { + // reactively respond to auth changes + effect(async () => { + const user = this.authService.user(); // signal + if (user?.uid) { + this.loading.set(true); + try { + const stories = await this.storyService.getUserStory(user.uid); + + const storiesArray = stories.map((v) => ({ ...v.data(), id: v.id })); + this.userStories.set(storiesArray); + console.log(this.userStories()); + } catch (err) { + console.error('Error loading stories:', err); + } finally { + this.loading.set(false); + } + } + }); + } + + openConfirmationModal(storyId: string): void { + this.selectedStoryId.set(storyId); + this.isModalOpen.set(true); + } + + closeConfirmationModal(): void { + this.isModalOpen.set(false); + this.selectedStoryId.set(null); + } + + async confirmUnsave(): Promise { + const storyId = this.selectedStoryId(); + const user = this.authService.user(); + + if (storyId && user?.uid) { + const result = await this.storyService.removeSavedStory( + storyId, + user.uid + ); + if (result.success) { + this.userStories.update((stories) => + stories.filter((story) => story.id !== storyId) + ); + } else { + // Optional: handle error with a user-facing message + console.error('Failed to unsave story:', result.message); + } + this.closeConfirmationModal(); + } + } +} diff --git a/src/app/components/signin/signin.css b/src/app/components/signin/signin.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/signin/signin.html b/src/app/components/signin/signin.html new file mode 100644 index 0000000..a155bfd --- /dev/null +++ b/src/app/components/signin/signin.html @@ -0,0 +1,61 @@ +
+
+ +
+

Kidlytics

+

AI Powered Story Teller

+
+ + +
+ + + + + +
+ + +
+
+ or +
+
+ + + + + + @if(error()) { +

Could not Sign In

+ } +
+
diff --git a/src/app/components/signin/signin.ts b/src/app/components/signin/signin.ts new file mode 100644 index 0000000..2e4886e --- /dev/null +++ b/src/app/components/signin/signin.ts @@ -0,0 +1,64 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + signal, +} from '@angular/core'; +import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; +import { AuthService } from '../../services/auth.service'; +import { Router } from '@angular/router'; +@Component({ + selector: 'app-signin', + imports: [ReactiveFormsModule], + templateUrl: './signin.html', + styleUrl: './signin.css', +}) +export class Signin { + private readonly auth = inject(AuthService); + private readonly fb = inject(FormBuilder); + + private readonly router = inject(Router); + + readonly loading = signal(false); + readonly error = signal(null); + + readonly form = this.fb.nonNullable.group({ + email: ['', [Validators.required, Validators.email]], + password: ['', Validators.required], + }); + + readonly isValid = computed(() => this.form.valid); + + async onEmailLogin(): Promise { + if (!this.form.valid) return; + this.loading.set(true); + this.error.set(null); + + try { + const { email, password } = this.form.getRawValue(); + const user = await this.auth.emailSignIn(email, password); + console.log(user); + } catch (err: unknown) { + this.error.set((err as Error).message); + } finally { + this.loading.set(false); + } + } + + async onGoogleLogin(): Promise { + this.loading.set(true); + this.error.set(null); + + try { + const user = await this.auth.googleSignIn(); + console.log(user); + this.router.navigate(['/story']); + // show toast, save user object in local storage and navigate to create page + } catch (err: unknown) { + this.error.set((err as Error).message); + } finally { + this.loading.set(false); + } + } +} diff --git a/src/app/components/signup/signup.css b/src/app/components/signup/signup.css new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/signup/signup.html b/src/app/components/signup/signup.html new file mode 100644 index 0000000..7ff65c9 --- /dev/null +++ b/src/app/components/signup/signup.html @@ -0,0 +1,54 @@ +
+
+ +
+

Kidlytics

+

+ AI Powered Story Teller for Kids +

+
+ + +
+ + + + + + +
+ + + @if(error()) { +

Error while Sign up

+ } +
+
diff --git a/src/app/components/signup/signup.ts b/src/app/components/signup/signup.ts new file mode 100644 index 0000000..f37d707 --- /dev/null +++ b/src/app/components/signup/signup.ts @@ -0,0 +1,56 @@ +import { + ChangeDetectionStrategy, + Component, + signal, + inject, +} from '@angular/core'; +import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms'; +import { AuthService } from '../../services/auth.service'; +@Component({ + selector: 'app-signup', + imports: [ReactiveFormsModule], + templateUrl: './signup.html', + styleUrl: './signup.css', +}) +export class Signup { + private readonly auth = inject(AuthService); + private readonly fb = inject(FormBuilder); + + readonly loading = signal(false); + readonly error = signal(null); + + readonly form = this.fb.nonNullable.group({ + email: ['', [Validators.required, Validators.email]], + password: ['', Validators.required], + }); + + async onSignup(): Promise { + if (!this.form.valid) return; + this.loading.set(true); + this.error.set(null); + + try { + const { email, password } = this.form.getRawValue(); + const newUser = await this.auth.emailSignup(email, password); + console.log(newUser); + } catch (err: unknown) { + this.error.set((err as Error).message); + } finally { + this.loading.set(false); + } + } + async onGoogleLogin(): Promise { + this.loading.set(true); + this.error.set(null); + + try { + const user = await this.auth.googleSignIn(); + console.log(user); + // show toast, save user object in local storage and navigate to create page + } catch (err: unknown) { + this.error.set((err as Error).message); + } finally { + this.loading.set(false); + } + } +} diff --git a/src/app/header/header.html b/src/app/header/header.html index 52a5716..5ba9cef 100644 --- a/src/app/header/header.html +++ b/src/app/header/header.html @@ -2,6 +2,16 @@ + + @if(authStore.isLoggedIn()){ + + Saved Stories + + + }
+ + @if(authStore.isLoggedIn()){ + + + }@else{ + + + } diff --git a/src/app/header/header.ts b/src/app/header/header.ts index c83957c..0b3705e 100644 --- a/src/app/header/header.ts +++ b/src/app/header/header.ts @@ -1,10 +1,21 @@ -import { Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; import { MatTooltip } from '@angular/material/tooltip'; import { RouterLink } from '@angular/router'; +import { AuthStore } from '../services/auth.store'; +import { AuthService } from '../services/auth.service'; @Component({ selector: 'app-header', imports: [MatTooltip, RouterLink], + standalone: true, templateUrl: './header.html', + changeDetection: ChangeDetectionStrategy.OnPush, }) -export class Header {} +export class Header { + readonly authStore = inject(AuthStore); + readonly authService = inject(AuthService); + + onLogout() { + this.authService.logout(); + } +} diff --git a/src/app/services/auth.service.ts b/src/app/services/auth.service.ts new file mode 100644 index 0000000..ff203ad --- /dev/null +++ b/src/app/services/auth.service.ts @@ -0,0 +1,31 @@ +import { Injectable, inject } from '@angular/core'; +import { + GoogleAuthProvider, + signInWithPopup, + signInWithEmailAndPassword, + createUserWithEmailAndPassword, + signOut, + UserCredential, +} from 'firebase/auth'; + +import { auth } from '../../../firebase'; + +@Injectable({ providedIn: 'root' }) +export class AuthService { + googleSignIn(): Promise { + const provider = new GoogleAuthProvider(); + return signInWithPopup(auth, provider); + } + + emailSignIn(email: string, password: string): Promise { + return signInWithEmailAndPassword(auth, email, password); + } + + emailSignup(email: string, password: string): Promise { + return createUserWithEmailAndPassword(auth, email, password); + } + + logout(): Promise { + return signOut(auth); + } +} diff --git a/src/app/services/auth.store.ts b/src/app/services/auth.store.ts new file mode 100644 index 0000000..492f6c8 --- /dev/null +++ b/src/app/services/auth.store.ts @@ -0,0 +1,23 @@ +import { Injectable, inject, signal, computed } from '@angular/core'; +import { onAuthStateChanged, User } from 'firebase/auth'; +import { auth } from '../../../firebase'; + +@Injectable({ providedIn: 'root' }) +export class AuthStore { + // signals + private readonly _user = signal(null); + readonly user = computed(() => this._user()); + readonly isLoggedIn = computed(() => !!this._user()); + + constructor() { + // Subscribe to Firebase auth changes + onAuthStateChanged(auth, (firebaseUser) => { + this._user.set(firebaseUser); + }); + } + + // useful getter + get currentUser(): User | null { + return this._user(); + } +} diff --git a/src/app/services/save.story.service.ts b/src/app/services/save.story.service.ts new file mode 100644 index 0000000..79dd057 --- /dev/null +++ b/src/app/services/save.story.service.ts @@ -0,0 +1,101 @@ +import { Injectable } from '@angular/core'; +import { + doc, + updateDoc, + arrayUnion, + collection, + query, + where, + getDocs, + getDoc, + arrayRemove, +} from 'firebase/firestore'; + +import { db } from '../../../firebase'; +import { NO_OF_SAVED_STORIES_ALLOWED } from '../../constants/limits.constants'; + +@Injectable({ + providedIn: 'root', +}) +export class StorySaveService { + async saveStory( + documentId: string, + uid: string + ): Promise<{ success: boolean; message: string }> { + try { + const storiesRef = collection(db, 'stories'); + + // Step 1: Count how many stories this user has saved + const q = query(storiesRef, where('savedBy', 'array-contains', uid)); + const snapshot = await getDocs(q); + const savedCount = snapshot.size; + + if (savedCount >= NO_OF_SAVED_STORIES_ALLOWED) { + return { + success: false, + message: 'You can only save up to 5 stories.', + }; + } + + // Step 2: Check if this story already has the uid in savedBy + const storyRef = doc(db, `stories/${documentId}`); + const storySnap = await getDoc(storyRef); + + if (storySnap.exists()) { + const data = storySnap.data(); + const alreadySaved = + Array.isArray(data['savedBy']) && data['savedBy'].includes(uid); + + if (alreadySaved) { + return { + success: false, + message: 'You have already saved this story.', + }; + } + } + + // Step 3: Add uid to savedBy array + await updateDoc(storyRef, { + savedBy: arrayUnion(uid), + }); + + return { success: true, message: 'Story saved successfully.' }; + } catch (error: any) { + console.error('Error saving story:', error); + return { + success: false, + message: error.message || 'Failed to save story.', + }; + } + } + + async getUserStory(uid: string) { + const storiesRef = collection(db, 'stories'); + const q = query(storiesRef, where('savedBy', 'array-contains', uid)); + const snapshot = await getDocs(q); + return snapshot.docs; + } + async removeSavedStory( + documentId: string, + uid: string + ): Promise<{ success: boolean; message: string }> { + try { + const storyRef = doc(db, `stories/${documentId}`); + + await updateDoc(storyRef, { + savedBy: arrayRemove(uid), + }); + + return { + success: true, + message: 'Story removed from saved list.', + }; + } catch (error: any) { + console.error('Error removing saved story:', error); + return { + success: false, + message: error.message || 'Failed to remove saved story.', + }; + } + } +} diff --git a/src/constants/limits.constants.ts b/src/constants/limits.constants.ts new file mode 100644 index 0000000..13b88dc --- /dev/null +++ b/src/constants/limits.constants.ts @@ -0,0 +1 @@ +export const NO_OF_SAVED_STORIES_ALLOWED = 5;