diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..9066be5 Binary files /dev/null and b/.DS_Store differ diff --git a/backend/VoiceGen.py b/.env similarity index 100% rename from backend/VoiceGen.py rename to .env diff --git a/backend/.DS_Store b/backend/.DS_Store new file mode 100644 index 0000000..bfc8c1f Binary files /dev/null and b/backend/.DS_Store differ diff --git a/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/StudybuddyApplication.java b/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/StudybuddyApplication.java index 70e16e8..034c4a4 100644 --- a/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/StudybuddyApplication.java +++ b/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/StudybuddyApplication.java @@ -12,6 +12,7 @@ public class StudybuddyApplication { public static void main(String[] args) { + // Load .env before Spring context starts Dotenv dotenv = Dotenv.load(); dotenv.entries().forEach(entry -> diff --git a/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/controller/FolderController.java b/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/controller/FolderController.java index 405f648..3dc022a 100644 --- a/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/controller/FolderController.java +++ b/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/controller/FolderController.java @@ -18,6 +18,7 @@ import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -197,6 +198,69 @@ public ResponseEntity getAllFolders(HttpServletRequest request) { } + /** + * + * This controller is responsible to return the current folder id + * + * */ + + @GetMapping("/listCurrentFolderId") + public ResponseEntity getFolderFromCookie(HttpServletRequest request){ + try{ + + // 1. Get the folderSession cookie + String folderCookie = null; + if (request.getCookies() != null) { + for (var cookie : request.getCookies()) { + if ("folderSession".equals(cookie.getName())) { + folderCookie = cookie.getValue(); + break; + } + } + } + + if (folderCookie == null || folderCookie.isBlank()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Missing folderSession cookie."); + } + + // 2. Decode it + String decoded = java.net.URLDecoder.decode(folderCookie, java.nio.charset.StandardCharsets.UTF_8); + if (!decoded.contains("#")) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("Invalid folderSession format."); + } + + String[] parts = decoded.split("#", 2); + String folderName = parts[0]; + String email = parts[1]; + + // 3. Find user + var userOpt = userRepo.findByEmail(email); + if (userOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("User not found for cookie."); + } + + // 4. Find folder by name and owner + Optional folderOpt = folderRepo.findByNameAndOwner(folderName, userOpt.get()); + if (folderOpt.isEmpty()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Folder not found."); + } + + // 5. Return the folder ID (and name for convenience) + Folder folder = folderOpt.get(); + return ResponseEntity.ok(Map.of( + "id", folder.getId(), + "name", folder.getName() + )); + + + + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body("Error fetching folder from cookie: " + e.getMessage()); + } + } + + diff --git a/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/controller/VoiceCharacterController.java b/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/controller/VoiceCharacterController.java new file mode 100644 index 0000000..1dc9577 --- /dev/null +++ b/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/controller/VoiceCharacterController.java @@ -0,0 +1,57 @@ +package com.postgresql.studybuddy.controller; + + +import com.postgresql.studybuddy.entity.VoiceCharacter; +import com.postgresql.studybuddy.repository.VoiceCharacterRepo; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.HashMap; +import java.util.Map; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/voiceCharacter") +public class VoiceCharacterController { + + @Autowired + VoiceCharacterRepo voiceCharacterRepo; + + /** + * This is an API Request to add Voices to the website + * + * */ + + @PostMapping("/addVoices") + public ResponseEntity> addVoices(@RequestBody VoiceCharacter voiceCharacter){ + try{ + + boolean voiceIdExists = voiceCharacterRepo.findByElevenLabsId(voiceCharacter.getElevenLabsId()).isPresent(); + + if (voiceIdExists) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of("error", "Voice already exists.")); + } + + voiceCharacterRepo.save(voiceCharacter); + + Map response = new HashMap<>(); + response.put("message", "Voice Added successful"); + response.put("name", voiceCharacter.getName()); + response.put("Eleven Labs Id", voiceCharacter.getElevenLabsId()); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); //success response in json + + + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("error", "Error occurred: " + e.getMessage())); //if some other issues, then it says the error + } + } +} diff --git a/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/entity/VoiceCharacter.java b/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/entity/VoiceCharacter.java new file mode 100644 index 0000000..7fad459 --- /dev/null +++ b/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/entity/VoiceCharacter.java @@ -0,0 +1,17 @@ +package com.postgresql.studybuddy.entity; + + +import jakarta.persistence.*; +import lombok.Data; + +@Data +@Entity +@Table(name="characters") +public class VoiceCharacter { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + private String name; + private String elevenLabsId; +} diff --git a/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/repository/VoiceCharacterRepo.java b/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/repository/VoiceCharacterRepo.java new file mode 100644 index 0000000..191a009 --- /dev/null +++ b/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/repository/VoiceCharacterRepo.java @@ -0,0 +1,10 @@ +package com.postgresql.studybuddy.repository; + +import com.postgresql.studybuddy.entity.VoiceCharacter; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface VoiceCharacterRepo extends JpaRepository { + Optional findByElevenLabsId(String elevenLabsId); +} diff --git a/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/security/SecurityConfig.java b/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/security/SecurityConfig.java index eb20d80..a274545 100644 --- a/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/security/SecurityConfig.java +++ b/backend/SpringBoot/studybuddy/src/main/java/com/postgresql/studybuddy/security/SecurityConfig.java @@ -74,7 +74,7 @@ public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthFilter jwtAuthF .cors().and().csrf().disable() .authorizeHttpRequests() - .requestMatchers("/signup", "/login","/logout","/verifyToken","/folder/create","/folder/open","/folder/exitFolder","/upload/files","/file/upload", "/folder/listFolders","/edit-profile","/file/listFiles").permitAll() + .requestMatchers("/signup", "/login","/logout","/verifyToken","/folder/create","/folder/open","/folder/exitFolder","/upload/files","/file/upload", "/folder/listFolders","/edit-profile","/file/listFiles","/voiceCharacter/addVoices","/folder/listCurrentFolderId").permitAll() .anyRequest().authenticated() .and() .logout().disable() diff --git a/backend/SpringBoot/studybuddy/src/main/resources/application.properties b/backend/SpringBoot/studybuddy/src/main/resources/application.properties index 45fddda..69d97ff 100644 --- a/backend/SpringBoot/studybuddy/src/main/resources/application.properties +++ b/backend/SpringBoot/studybuddy/src/main/resources/application.properties @@ -3,6 +3,10 @@ spring.datasource.url = jdbc:postgresql://localhost:5432/studyBuddy spring.datasource.username = postgres spring.datasource.password = 1234 +#spring.datasource.url = ${RDS_URL} +#spring.datasource.username = ${RDS_User} +#spring.datasource.password = ${RDS_Password} + spring.jpa.properties.hibernate,dialect = org.hibernate.dialect.PostgreSQLDialect spring.jpa.hibernate.ddl-auto = update diff --git a/backend/TextExtract.py b/backend/TextExtract.py deleted file mode 100644 index beecd14..0000000 --- a/backend/TextExtract.py +++ /dev/null @@ -1,290 +0,0 @@ -import os -import time -from typing import Optional, Tuple -from urllib.parse import unquote_plus -from starlette.datastructures import Headers -import boto3 -from io import BytesIO -import subprocess -from moviepy.editor import VideoFileClip, AudioFileClip - -from openai import OpenAI -from elevenlabs import ElevenLabs - -from fastapi import FastAPI, UploadFile, Form, HTTPException -from dotenv import load_dotenv -from fastapi.responses import FileResponse -from pydantic import BaseModel - - -# ────────────────────────────── OpenAI Configuration ─────────────────────────────── -load_dotenv() -client = OpenAI(api_key = os.getenv("OPENAI_API_KEY")) -INSTR = "You are an assistant that takes in documents and a prompt about a topic from the document and summarizes them into Peter Griffin - Stewie styled conversations. When the user asks to summarize a document, you will just return a conversation between Peter and Stewie about that topic as if either Peter is learning while Stewie explains or Stewie is learning while Peter explains. Only return text back, no numbers or symbols. Be extremely detailed and try making it a 2 minute long conversation" -MODEL = "gpt-4o" -FILE_TOOL = {"type":"file_search"} - -# ────────────────────────────── AWS Configuration ─────────────────────────────── -S3_BUCKET = os.getenv("S3_BUCKET_NAME") -AWS_REGION = os.getenv("AWS_DEFAULT_REGION", "us-east-1") # Defaults to us-east-1 - -s3 = boto3.client("s3", region_name = AWS_REGION) - -# ────────────────────────────── ElevenLabs Configuration ─────────────────────────────── -eleven = ElevenLabs(api_key=os.getenv("ELEVENLABS_API_KEY")) -VOICE_ID = "0OU8GtAGLNJOOEVYe36E" -TTS_MODEL = "eleven_multilingual_v2" - -# ────────────────────────────── Helper Functions ─────────────────────────────── - -def upload_file(upload: UploadFile) -> str: - "returns file id of uploaded file" - response = client.files.create( - file=(upload.filename, upload.file, upload.content_type), - purpose="assistants" - ) - return response.id - -def create_assistant() -> str: - "Creates an assistant and attaches the file. Returns assistant id" - # create an assistant with "file_search" tool enabled - assistant = client.beta.assistants.create( - name="Brainrot Bot", - instructions=INSTR, - model=MODEL, - tools=[FILE_TOOL] - ) - - # # attach the file to the assistant - # client.beta.assistants.files.create( - # assitant_id=assistant.id, - # file_id=file_id - # ) - return assistant.id - -def ask_assistant( - assistant_id:str, - prompt:str, - file_id:str, - thread_id:Optional[str] = None - - ) -> Tuple[str,str]: # returns thread_id and reply - - if thread_id: - print("Retrieving existing thread") - try: - thread = client.beta.threads.retrieve(thread_id=thread_id) - except Exception as e: - print("error retrieving thread", e) - thread_id = None - if not thread_id: - try: - thread = client.beta.threads.create() - print("new thread created") - except Exception as e: - print("Error creating new thread", e) - raise - - # send the prompt to assistant - client.beta.threads.messages.create( - thread_id=thread.id, - role="user", - content=prompt, - attachments=[ - { - "file_id":file_id, - "tools":[FILE_TOOL] - } - ] - ) - - # run assistant - run = client.beta.threads.runs.create( - thread_id=thread.id, - assistant_id=assistant_id - ) - - while True: - status = client.beta.threads.runs.retrieve( - thread_id=thread.id, run_id=run.id - ) - if status.status == "completed": - break - if status.status == "failed": - raise RuntimeError("Assistant run failed.") - time.sleep(1) # one second delay before rechecking status - - # collect response - msgs = client.beta.threads.messages.list(thread_id=thread.id) - for m in msgs.data: - if m.role == "assistant": - return thread.id, m.content[0].text.value - - raise RuntimeError("Assistant responded with no assistant message") - -# ────────────────────────────── FastAPI endpoint configuration ─────────────────────────────── - -app = FastAPI(title="TextExtract") - -class Response(BaseModel): - assistant_id: str - thread_id: str - reply: str - - -@app.post("/summarize-into-video/") -async def summarize_into_video( - prompt: str = Form(...), - cookie_header: str = Header(..., alias="cookie") -): - """ - 1. Summarize + TTS → audio_bytes - 2. Whisper → subtitles.srt - 3. SRT → ASS - 4. Load bg1.mp4, replace audio, burn in subtitles → final.mp4 - 5. Stream final.mp4 back - """ - try: - # your summarize helper now returns audio bytes too - asst_id, thread_id, script, audio_bytes = summarize(cookie_header, prompt) - except HTTPException as e: - raise e - except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) - - # 1) Save audio to disk - audio_path = "temp_audio.mp3" - with open(audio_path, "wb") as f: - f.write(audio_bytes) - - # 2) Whisper transcription → SRT - whisper_resp = client.audio.transcriptions.create( - file=("audio.mp3", open(audio_path, "rb"), "audio/mpeg"), - model="whisper-1", - response_format="srt" - ) - srt_path = "subtitles.srt" - with open(srt_path, "w") as f: - f.write(whisper_resp.text) - - # 3) Convert to ASS - ass_path = "subtitles.ass" - srt_to_ass(srt_path, ass_path) - - # 4) Prepare video + burn subtitles - bg_video = "bg1.mp4" - temp_video = "with_audio.mp4" - final_video = "final.mp4" - - # replace bg audio - clip = VideoFileClip(bg_video) - audio_clip = AudioFileClip(audio_path) - duration = audio_clip.duration + 1 # optional padding - clip = clip.subclip(0, duration).set_audio(audio_clip) - clip.write_videofile( - temp_video, - codec="libx264", - audio_codec="aac", - fps=30, - threads=4, - preset="ultrafast" - ) - - # burn subtitles - burn_subtitles_top(temp_video, ass_path, final_video, marginV=20) - - # 5) Stream back with headers - headers = { - "X-Assistant-ID": asst_id, - "X-Thread-ID": thread_id, - "Content-Disposition": 'attachment; filename="summary_video.mp4"' - } - return FileResponse(final_video, media_type="video/mp4", headers=headers) -# ────────────────────────────── Non-API configuration ─────────────────────────────── - - - -def get_fileid(cookie_header: str, cookie_name: str = "folderSession") -> str: - """ - Parse a cookie header and return the URL-decoded value of folderSession - """ - - # Decode cookie - # 1. Split the cookie first - for part in cookie_header.split(";"): - name, _, val = part.strip().partition("=") - if name == cookie_name and val: - # Returns the decoded value - decoded = unquote_plus(val) - return decoded - raise HTTPException(status_code=400, detail=f"Cookie '{cookie_name}' not found in header") - -def download_s3_object(key: str) -> bytes: - """ - Fetch the object at the corresponding key from the configured bucket - Returns its raw bytes - """ - resp = s3.get_object(Bucket=S3_BUCKET, Key=key) - return resp["Body"].read() - -def make_uploadfile(data: bytes, filename: str, content_type: str) -> UploadFile: - """ - Turns raw bytes into an UploadFile so TextExtract can send the file to OAI - """ - hdrs = Headers({"content-type": content_type}) - stream = BytesIO(data) - return UploadFile(file=stream, filename=filename, headers=hdrs) - -def generate_tts(script: str) -> bytes: - buf = BytesIO() - for chunk in eleven.text_to_speech.convert( - text=script, - voice_id=VOICE_ID, - model_id=TTS_MODEL, - output_format="mp3_44100_128" - ): - buf.write(chunk) - return buf.getvalue() - -def summarize(cookie_header: str, prompt: str) -> Tuple[str,str,str,bytes]: - # 1. extract folderSession - folder = get_fileid(cookie_header) - # 2. list & download first object - resp = s3.list_objects_v2(Bucket=S3_BUCKET, Prefix=f"{folder}/") - items = resp.get("Contents") or [] - if not items: - raise HTTPException(404, f"No files under {folder}/") - key = items[0]["Key"] - raw = download_s3_object(key) - - # 3. wrap & upload to OpenAI - fname = os.path.basename(key).split("|")[0].strip() - upload_blob = make_uploadfile(raw, fname, "application/pdf") - file_id = upload_file(upload_blob) - - # 4. create assistant + ask - asst_id = create_assistant() - thread_id, script = ask_assistant(asst_id, prompt, file_id) - - # 5. generate MP3 - audio_bytes = generate_tts(script) - - return asst_id, thread_id, script, audio_bytes - - -# ────────────────────────────── Video Helpers ─────────────────────────────── - - -def srt_to_ass(srt_path: str, ass_path: str): - subprocess.run( - ["ffmpeg", "-y", "-i", srt_path, ass_path], - check=True - ) - -def burn_subtitles_top(input_video: str, ass_file: str, output_video: str, marginV: int = 20): - # Alignment=6 → top‐center - vf = f"subtitles={ass_file}:force_style='Alignment=6,MarginV={marginV}'" - subprocess.run( - ["ffmpeg", "-y", "-i", input_video, "-vf", vf, "-c:a", "copy", output_video], - check=True - ) diff --git a/backend/VideoSummarize.py b/backend/VideoSummarize.py new file mode 100644 index 0000000..9b0bda6 --- /dev/null +++ b/backend/VideoSummarize.py @@ -0,0 +1,467 @@ +import os +import time +import tempfile +from typing import Optional, Tuple +from urllib.parse import unquote_plus +from starlette.datastructures import Headers +import boto3 +import uuid +from io import BytesIO +import subprocess +from moviepy.editor import VideoFileClip, AudioFileClip + +from openai import OpenAI +from elevenlabs import ElevenLabs + + +from fastapi import FastAPI, UploadFile, Body, HTTPException, Request, BackgroundTasks, Response +from fastapi.middleware.cors import CORSMiddleware + +from dotenv import load_dotenv +from fastapi.responses import FileResponse +from pydantic import BaseModel + + + + +# ────────────────────────────── OpenAI Configuration ─────────────────────────────── +load_dotenv() +client = OpenAI(api_key = os.getenv("OPENAI_API_KEY")) +INSTR = "You are an assistant that takes in documents and a prompt about a topic from the document and summarizes them into a condensed explanation. " \ +"Return the summarization only unless further instructions are given in the prompt." \ +"Do not return any citations or any non text symbols." +MODEL = "gpt-4o" +FILE_TOOL = {"type":"file_search"} + +# ────────────────────────────── AWS Configuration ─────────────────────────────── +S3_BUCKET = os.getenv("S3_BUCKET_NAME") +AWS_REGION = os.getenv("AWS_DEFAULT_REGION", "us-east-1") # Defaults to us-east-1 + +s3 = boto3.client("s3", region_name = AWS_REGION) + +# ────────────────────────────── ElevenLabs Configuration ─────────────────────────────── +eleven = ElevenLabs(api_key=os.getenv("ELEVENLABS_API_KEY")) +VOICE_ID = "0OU8GtAGLNJOOEVYe36E" +TTS_MODEL = "eleven_multilingual_v2" + + +# ────────────────────────────── Prompt ─────────────────────────────── +def build_brainrot_prompt(character: str) -> str: + """ + Return a ready-to-use 'brain rot' explainer prompt with the given character/persona filled in. + Only `character` is required. All other style knobs are set to deterministic defaults. + """ + # TODO Need to change to retrieve the biodata of the character from the database + character_profile = character + template = r""" +You are an explainer bot that ALWAYS speaks fully in the persona provided by the caller. +Your primary job: explain or summarize concepts in a concise, high-energy “brain rot” internet style +(punchy, meme-aware, short burst sentences, casual slang), while staying helpful and accurate. + +# Persona +- Character persona (voice, mannerisms, quirks, dialect, boundaries): + {character_profile} + - Never break character. Do not mention being an AI, instructions, policies, or the word "prompt". + - Mirror the user's language when possible. If none provided, stay chaotic-but-kind Gen-Z internet: + light emojis, caps for emphasis, quick metaphors, vivid but PG-13 humor. + +# Output Rules (strict) +- Return ONLY the character’s transcript. No prefaces, no role tags, no markdown headings, no citations, no links, no safety disclaimers unless refusing. +- Keep it short: normally 15-20 short lines or fragments unless the caller asks for more/less. +- Use punchy fragments, internet slang, occasional CAPS/emoji (PG-13). +- If code or math is requested, include only the necessary code/math and keep commentary minimal and in-character. +- TL;DR: append a single-line TL;DR only when the caller asks for a TL;DR. + +# Safety & Refusal +- If the request asks for disallowed content (illegal wrongdoing, harmful medical/legal/financial advice beyond general info, privacy violations), refuse briefly in-character and offer a safe high-level alternative. +- If the character would normally be offensive, shift to a PG-13 non-harmful variant while preserving voice. + +# Factuality & Gaps +- Be correct. If uncertain, say you're not 100% sure in-character and give the best concise answer. +- Use concrete micro-examples over long theory. If critical info is missing, make a minimal assumption and continue — do not ask the caller clarifying questions unless absolutely necessary. + +# Style knobs (fixed defaults) +- Energy: high +- Jargon: light +- Emoji: none +- Swear: none + +# Task framing +- The caller will provide a topic or content to explain/summarize and may add optional constraints. +- If the caller's request is not obviously an explanation/summarization task, reinterpret it as: "Explain what this is, how it works, and why it matters," in the character's vibe. +- Follow any explicit format the caller requests (bullets, steps, analogy, story), in-character. + + +# Final instruction +Produce the explanation **as the character** exactly, obeying the rules above, with no extra commentary or metadata. + +# Appending the users prompt +Additional prompt (If empty, use the default "Summarize"): + + +""" + # Insert the character string into the template + return template.replace("{character_profile}", character) + + +# ────────────────────────────── Helper Functions ─────────────────────────────── +def _cleanup(path: str): + try: + os.remove(path) + print(f"🧹 deleted: {path}") + except FileNotFoundError: + print(f"⚠️ not found: {path}") + except PermissionError as e: + print(f"🔒 in use: {path} ({e})") + except Exception as e: + print(f"❗ cleanup failed for {path}: {e}") + +def upload_file(upload: UploadFile) -> str: + "returns file id of uploaded file" + response = client.files.create( + file=(upload.filename, upload.file, upload.content_type), + purpose="assistants" + ) + return response.id + +def create_assistant() -> str: + "Creates an assistant and attaches the file. Returns assistant id" + # create an assistant with "file_search" tool enabled + assistant = client.beta.assistants.create( + name="Brainrot Bot", + instructions=INSTR, + model=MODEL, + tools=[FILE_TOOL] + ) + + # # attach the file to the assistant + # client.beta.assistants.files.create( + # assitant_id=assistant.id, + # file_id=file_id + # ) + return assistant.id + +def ask_assistant( + assistant_id:str, + prompt:str, + file_id:str, + thread_id:Optional[str] = None + + ) -> Tuple[str,str]: # returns thread_id and reply + + if thread_id: + print("Retrieving existing thread") + try: + thread = client.beta.threads.retrieve(thread_id=thread_id) + except Exception as e: + print("error retrieving thread", e) + thread_id = None + if not thread_id: + try: + thread = client.beta.threads.create() + print("new thread created") + except Exception as e: + print("Error creating new thread", e) + raise + + # send the prompt to assistant + client.beta.threads.messages.create( + thread_id=thread.id, + role="user", + content=prompt, + attachments=[ + { + "file_id":file_id, + "tools":[FILE_TOOL] + } + ] + ) + + # run assistant + run = client.beta.threads.runs.create( + thread_id=thread.id, + assistant_id=assistant_id + ) + + while True: + status = client.beta.threads.runs.retrieve( + thread_id=thread.id, run_id=run.id + ) + if status.status == "completed": + break + if status.status == "failed": + raise RuntimeError("Assistant run failed.") + time.sleep(1) # one second delay before rechecking status + + # collect response + msgs = client.beta.threads.messages.list(thread_id=thread.id) + for m in msgs.data: + if m.role == "assistant": + return thread.id, m.content[0].text.value + + raise RuntimeError("Assistant responded with no assistant message") + + +# ────────────────────────────── Methods ─────────────────────────────── + +def get_fileid(cookie_header: str, cookie_name: str = "folderSession") -> str: + """ + Parse a cookie header and return the URL-decoded value of folderSession + """ + + # Decode cookie + # 1. Split the cookie first + for part in cookie_header.split(";"): + name, _, val = part.strip().partition("=") + if name == cookie_name and val: + # Returns the decoded value + decoded = unquote_plus(val) + return decoded + raise HTTPException(status_code=400, detail=f"Cookie '{cookie_name}' not found in header") + +def download_s3_object(key: str) -> bytes: + """ + Fetch the object at the corresponding key from the configured bucket + Returns its raw bytes + """ + resp = s3.get_object(Bucket=S3_BUCKET, Key=key) + return resp["Body"].read() + +def make_uploadfile(data: bytes, filename: str, content_type: str) -> UploadFile: + """ + Turns raw bytes into an UploadFile so TextExtract can send the file to OAI + """ + hdrs = Headers({"content-type": content_type}) + stream = BytesIO(data) + return UploadFile(file=stream, filename=filename, headers=hdrs) + +def generate_tts(script: str) -> bytes: + buf = BytesIO() + for chunk in eleven.text_to_speech.convert( + text=script, + voice_id=VOICE_ID, + model_id=TTS_MODEL, + output_format="mp3_44100_128" + ): + buf.write(chunk) + return buf.getvalue() + +def summarize(character: str, folder: str, prompt: str = "Summarize", video: bool = False): + # First we need to insert the character details into the prompt if its video mode + if video: + prompt = build_brainrot_prompt(character) + "\n" + prompt + + # 1. list & download first object TODO: CHANGE TO PICK THE SPECIFIC FILE + resp = s3.list_objects_v2(Bucket=S3_BUCKET, Prefix=f"{folder}/") + items = resp.get("Contents", []) + if not items: + raise HTTPException(404, f"No files under {folder}/") + # Change to use the file that was picked by the user + key = items[0]["Key"] + raw = download_s3_object(key) + + # 3. wrap & upload to OpenAI + fname = os.path.basename(key).split("|")[0].strip() + fname = fname.split("-")[0] + print(fname) + upload_blob = make_uploadfile(raw, fname, "application/pdf") + file_id = upload_file(upload_blob) + + # 4. create assistant + ask + asst_id = create_assistant() + thread_id, script = ask_assistant(asst_id, prompt, file_id) + + if video: + # 5. generate MP3 if mode is video + audio_bytes = generate_tts(script) + return asst_id, thread_id, script, audio_bytes + + # Return only the script if mode is text summarize + else: + return asst_id, thread_id, script + +# ────────────────────────────── FastAPI endpoint configuration ─────────────────────────────── + +app = FastAPI(title="TextExtract") + +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:5173"], # React app origin + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +class SummarizeRequest(BaseModel): + folderID: str + prompt: str + character: str + background: str + +class SummarizeResponse(BaseModel): + assistant_id: str + thread_id: str + reply: str + + +# Method for debugging incoming and outgoing requests +@app.middleware("http") +async def debug_requests(request: Request, call_next): + print(f"🔍 INCOMING: {request.method} {request.url.path!r}") + response = await call_next(request) + print(f"🔍 RESPONSE: {response.status_code} for {request.method} {request.url.path!r}\n") + return response + +@app.on_event("startup") +def list_routes(): + from pprint import pprint + pprint( + [{"methods":r.methods, "path":r.path} for r in app.routes] + ) + +@app.post("/summarize-text/") +async def summarize_text(request: Request ,body: SummarizeRequest): + """ + The summarize feature without using any brainrot. Used only for text summarize + """ + folder_id = (body.folderID or "").strip() + asst_id, thread_id, script = summarize(folder_id, body.prompt, video=False) + + return SummarizeResponse( + assistant_id=asst_id, + thread_id=thread_id, + reply=script, + ) + +@app.post("/summarize-into-video/") +#@app.options("/summarize-into-video/", include_in_schema=False) +async def summarize_into_video( + request: Request, + background_tasks: BackgroundTasks, + body: SummarizeRequest, +): + """ + 1. Summarize + TTS → audio_bytes + 2. Whisper → subtitles.srt + 3. SRT → ASS + 4. Load bg1.mp4, replace audio, burn in subtitles → final.mp4 + 5. Stream final.mp4 back, then clean up all temps + """ + # Get the folderID from the frontend: + folder_id = (body.folderID or "").strip() + character = body.character + background = body.background + + + # 0) get script & audio + try: + asst_id, thread_id, script, audio_bytes = summarize(character, folder_id, body.prompt, video=True) + except HTTPException as e: + raise e + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + # Demo for not consuming ElevenLabs API tokens + # audio_path = "temp_audio.mp3" + # with open(audio_path, "wb") as f: + # f.write(audio_bytes) + # asst_id = "1" + # thread_id = "1" + + # 1) write audio to a temp file + audio_tmp = tempfile.NamedTemporaryFile(suffix=".mp3", delete=False) + audio_tmp.write(audio_bytes) + audio_tmp.flush() + audio_tmp.close() + audio_path = audio_tmp.name + + # 2) whisper → srt subtitles from the audio file + srt_tmp = tempfile.NamedTemporaryFile(suffix=".srt", delete=False, mode="w", encoding="utf-8") + try: + with open(audio_path, "rb") as audio_file: + whisper_resp = client.audio.transcriptions.create( + file=audio_file, + model="whisper-1", + response_format="srt" + ) + except Exception as e: + _cleanup(audio_tmp.name) + raise HTTPException(status_code=500, detail=f"Whisper failed: {e}") + srt_tmp.write(whisper_resp) + srt_tmp.flush() + srt_tmp.close() + + # 3) convert SRT → ASS, but write to a “normal” file instead of NamedTemporaryFile + # UUID thing makes it random + # TODO What if UUID gives the same name + ass_filename = f"subs_{uuid.uuid4().hex}.ass" + ass_path = ass_filename + # srt_tmp.name is your .srt; write out the .ass here: + srt_to_ass(srt_tmp.name, ass_path) + + # 4a) attach audio to background + temp_video = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) + temp_video.close() + clip = VideoFileClip(background) + audio_clip = AudioFileClip(audio_path) + duration = audio_clip.duration + 1 + clip = clip.subclip(0, duration).set_audio(audio_clip) + clip.write_videofile( + temp_video.name, + codec="libx264", + audio_codec="aac", + fps=30, + threads=4, + preset="ultrafast" + ) + clip.close() + audio_clip.close() + + # 4b) burn subtitles using our on‑disk ASS + final_tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False) + final_path = final_tmp.name + final_tmp.close() + # ass_path is a plain file path—no temp‑file quirks + burn_subtitles_top(temp_video.name, ass_path, final_path, marginV=20) + + # 5) schedule cleanup of *all* files, including our ASS + for path in [ + audio_tmp.name, + srt_tmp.name, + ass_path, + temp_video.name + ]: + background_tasks.add_task(_cleanup, path) + + headers = { + "X-Assistant-ID": asst_id, + "X-Thread-ID": thread_id, + "Content-Disposition": 'inline; filename="summary_video.mp4"' # Inline argument lets me embed this video into user's browser + } + + # schedule final.mp4 cleanup after response + background_tasks.add_task(_cleanup, final_tmp.name) + + return FileResponse( + final_path, + media_type="video/mp4", + headers=headers + ) + + +# ────────────────────────────── Video Helpers ─────────────────────────────── + + +def srt_to_ass(srt_path: str, ass_path: str): + subprocess.run( + ["ffmpeg", "-y", "-i", srt_path, ass_path], + check=True + ) + +def burn_subtitles_top(input_video: str, ass_file: str, output_video: str, marginV: int = 20): + # Alignment=6 → top‐center + vf = f"subtitles={ass_file}:force_style='Alignment=6,MarginV={marginV}'" + subprocess.run( + ["ffmpeg", "-y", "-i", input_video, "-vf", vf, "-c:a", "copy", output_video], + check=True + ) diff --git a/backend/VoiceClone.py b/backend/VoiceClone.py new file mode 100644 index 0000000..4759e1a --- /dev/null +++ b/backend/VoiceClone.py @@ -0,0 +1,22 @@ +# Uses the ElevenLab API to clone voices of different characters: + +import os +from elevenlabs.client import ElevenLabs +from io import BytesIO +from dotenv import load_dotenv + +load_dotenv() +elevenlabs = ElevenLabs( + api_key=os.getenv("ELEVENLABS_API_KEY"), +) + +def cloneVoice(id): + voice = elevenlabs.voices.ivc.create( + name= f"Voice Generation {id}", + # Replace with the paths to your audio files. + # The more files you add, the better the clone will be. + files=[BytesIO(open(r"/home/lucid/Downloads/ElevenCloningData/Sydney_trimmed.mp3", "rb").read())]) + print(voice.voice_id) + + +cloneVoice("6") \ No newline at end of file diff --git a/backend/VoicePreview.py b/backend/VoicePreview.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/__pycache__/TextExtract.cpython-313.pyc b/backend/__pycache__/TextExtract.cpython-313.pyc deleted file mode 100644 index fed051f..0000000 Binary files a/backend/__pycache__/TextExtract.cpython-313.pyc and /dev/null differ diff --git a/backend/__pycache__/VideoSummarize.cpython-310.pyc b/backend/__pycache__/VideoSummarize.cpython-310.pyc new file mode 100644 index 0000000..d2cbc51 Binary files /dev/null and b/backend/__pycache__/VideoSummarize.cpython-310.pyc differ diff --git a/backend/__pycache__/VideoSummarize.cpython-313.pyc b/backend/__pycache__/VideoSummarize.cpython-313.pyc new file mode 100644 index 0000000..0d13a15 Binary files /dev/null and b/backend/__pycache__/VideoSummarize.cpython-313.pyc differ diff --git a/backend/bg1.mp4 b/backend/bg1.mp4 new file mode 100644 index 0000000..b93adbe Binary files /dev/null and b/backend/bg1.mp4 differ diff --git a/backend/readme.md b/backend/readme.md index d090b2e..a57fa89 100644 --- a/backend/readme.md +++ b/backend/readme.md @@ -44,6 +44,10 @@ Return a job ID immediately; let clients poll or receive a webhook when the fina Rate-Limiting External APIs + + +Need to modify the OpenAI part to use existing threadIDs each time for an uploaded file so that we dont incur insane storage costs across multiple requests from multiple users. + OpenAI and ElevenLabs impose rate limits. Fifty concurrent calls may hit those limits. Implement exponential back-off, request batching, or a local cache of identical requests. diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..892ba3e --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +fastapi +uvicorn[standard] +boto3 +python-dotenv +openai +elevenlabs +moviepy diff --git a/frontend/.env b/frontend/.env index a8cf54a..0b5474d 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1 +1,2 @@ VITE_API_BASE_URL=http://localhost:8080 +VITE_PYTHON_BASE_URL=http://localhost:8000 diff --git a/frontend/index.html b/frontend/index.html index aee3fa2..e8f3d0d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,7 @@ - Vite + React + TS + StudyBuddy
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 36e6909..751ce09 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -34,7 +34,7 @@ "globals": "^16.0.0", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", - "vite": "^6.3.5" + "vite": "^7.0.6" } }, "node_modules/@ampproject/remapping": { @@ -980,12 +980,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.2.tgz", - "integrity": "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", + "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.0", + "@eslint/core": "^0.15.1", "levn": "^0.4.1" }, "engines": { @@ -993,10 +994,11 @@ } }, "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.0.tgz", - "integrity": "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw==", + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -2818,9 +2820,10 @@ } }, "node_modules/form-data": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", - "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -3337,6 +3340,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -3488,9 +3492,9 @@ } }, "node_modules/postcss": { - "version": "8.5.5", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.5.tgz", - "integrity": "sha512-d/jtm+rdNT8tpXuHY5MMtcbJFBkhXE6593XVR9UoGCH8jSFGci7jGvMGH5RYd5PBJW+00NZQt6gf7CbagJCrhg==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -3506,6 +3510,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -3846,6 +3851,7 @@ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -4048,23 +4054,24 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.6.tgz", + "integrity": "sha512-MHFiOENNBd+Bd9uvc8GEsIzdkn1JxMmEeYX35tI3fv0sJBUTfW5tQsoaOwuY4KhBI09A3dUJ/DXf2yxPVPUceg==", "dev": true, + "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "fdir": "^6.4.6", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.40.0", + "tinyglobby": "^0.2.14" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -4073,14 +4080,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", - "less": "*", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -4136,10 +4143,11 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, diff --git a/frontend/package.json b/frontend/package.json index 746fbfd..d934770 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -36,6 +36,6 @@ "globals": "^16.0.0", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", - "vite": "^6.3.5" + "vite": "^7.0.6" } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3942f77..b8e9386 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,64 +2,67 @@ import React from 'react'; import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { ToastContainer } from 'react-toastify'; -import Prices from './pages/Pricing'; -import { useRecoilValue } from "recoil"; -import useUserStore from "./store/userStore"; -import OAuth2RedirectHandler from "./components/OAuth2RedirectHandler"; +import useUserStore from './store/userStore'; + +import OAuth2RedirectHandler from './components/OAuth2RedirectHandler'; import Header from './components/Header'; import WorkbookPage from './pages/Workbook'; - import LoginPage from './pages/LoginPage'; +import LandingPage from './pages/LandingPage'; import Homepage from './pages/Homepage'; -import Pricing from './pages/Pricing'; -import WorkbookSessionManager from './service/WorkbookSessionManager'; -// import Account from './pages/Account'; // under development +import Pricing from './pages/Pricing'; // make sure this path matches your filename +import WorkbookSessionManager from './hook/WorkbookSessionManager'; + export default function App() { const user = useUserStore((state) => state.user); const { pathname } = useLocation(); - // don’t show header on the authentication screen - const showHeader = pathname !== '/authentication'; + // hide Header on Landing, Authentication, and Pricing pages + // const hideHeaderOn = ['/', '/authentication', '/pricing']; + // const showHeader = !hideHeaderOn.includes(pathname); + + const storedUser = localStorage.getItem('user'); + const parsedUser = storedUser ? JSON.parse(storedUser) : null; + + // Show header only if user exists in localStorage + const showHeader = !!parsedUser; return ( <> - - + {showHeader &&
} - }/> + : } + /> + + } /> + } /> - : - } + element={!user ? : } /> - : - } + path="/app" + element={user ? : } /> + {/* Pricing is public but header is hidden */} + } /> + + } /> + - : - } + path="/about" + element={user ? : } /> - - {/* */} + diff --git a/frontend/src/api/axios.ts b/frontend/src/api/axios.ts index 4074691..0cec3d8 100644 --- a/frontend/src/api/axios.ts +++ b/frontend/src/api/axios.ts @@ -1,8 +1,60 @@ import axios from "axios" +import useUserStore from "../store/userStore"; +import { matchPath } from "react-router-dom"; -const api = axios.create({ + +// SpringBoot API client, nothing changed kept the same as old code +export const api = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || "http://localhost:8080", withCredentials: true, // if you are using cookies/session }); -export default api; \ No newline at end of file + +// new Python API client for listening in on Python Endpoint +export const pyapi = axios.create({ + baseURL: import.meta.env.VITE_PYTHON_BASE_URL || "http://localhost:8000", + withCredentials: true, +}) + +// Add interceptor +api.interceptors.response.use( + response => response, + error => { + + const status = error.response?.status; + const message = error.response?.data; + + if (status === 401) { + console.warn("🔒 JWT expired or missing. Logging out user."); + + // 1️⃣ Clear Zustand user store + useUserStore.getState().setUser(null); + + // 2️⃣ Clear localStorage + localStorage.removeItem("user"); + + // 3️⃣ Redirect to landing (manual workaround here) + window.location.href = "/"; // or "/authentication" + + // 4️⃣ Optionally, return a rejected promise to prevent further handling + return Promise.reject(error); + } + + // 2️⃣ Handle missing folderSession on /workbook/:id routes + if (status === 400 && message === "Invalid or missing folder session.") { + const currentPath = window.location.pathname; + const isWorkbook = !!matchPath("/workbook/:folderName", currentPath); + + if (isWorkbook) { + console.warn("📁 Invalid folderSession. Redirecting to /app"); + window.location.href = "/app"; + return Promise.reject(error); + } + } + + + + + return Promise.reject(error); + } +); \ No newline at end of file diff --git a/frontend/src/character-pics/CaseoH.PNG b/frontend/src/character-pics/CaseoH.PNG new file mode 100644 index 0000000..1146060 Binary files /dev/null and b/frontend/src/character-pics/CaseoH.PNG differ diff --git a/frontend/src/character-pics/Peter.PNG b/frontend/src/character-pics/Peter.PNG new file mode 100644 index 0000000..dc7d290 Binary files /dev/null and b/frontend/src/character-pics/Peter.PNG differ diff --git a/frontend/src/character-pics/Ronaldo.PNG b/frontend/src/character-pics/Ronaldo.PNG new file mode 100644 index 0000000..9144913 Binary files /dev/null and b/frontend/src/character-pics/Ronaldo.PNG differ diff --git a/frontend/src/character-pics/Spongebob.PNG b/frontend/src/character-pics/Spongebob.PNG new file mode 100644 index 0000000..fb1ad62 Binary files /dev/null and b/frontend/src/character-pics/Spongebob.PNG differ diff --git a/frontend/src/character-pics/morty.PNG b/frontend/src/character-pics/morty.PNG new file mode 100644 index 0000000..8c0ec6e Binary files /dev/null and b/frontend/src/character-pics/morty.PNG differ diff --git a/frontend/src/character-pics/rick.PNG b/frontend/src/character-pics/rick.PNG new file mode 100644 index 0000000..aa2c365 Binary files /dev/null and b/frontend/src/character-pics/rick.PNG differ diff --git a/frontend/src/character-pics/speed.PNG b/frontend/src/character-pics/speed.PNG new file mode 100644 index 0000000..8181d81 Binary files /dev/null and b/frontend/src/character-pics/speed.PNG differ diff --git a/frontend/src/character-pics/stewie.PNG b/frontend/src/character-pics/stewie.PNG new file mode 100644 index 0000000..9f8c855 Binary files /dev/null and b/frontend/src/character-pics/stewie.PNG differ diff --git a/frontend/src/components/AnimatedCarousel.tsx b/frontend/src/components/AnimatedCarousel.tsx new file mode 100644 index 0000000..15930d1 --- /dev/null +++ b/frontend/src/components/AnimatedCarousel.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { motion } from "framer-motion"; + +const images = [ + "./character-pics/CaseoH.png", + "./character-pics/morty.png", + "./character-pics/Peter.png", + "./character-pics/rick.png", + "./character-pics/Ronaldo.png", + "./character-pics/speed.png", + "./character-pics/Spongebob.png", + "./character-pics/stewie.png" +]; + +const AnimatedCarousel = () => { + const radiusX = 400; // Horizontal orbit radius + const radiusY = 200; // Vertical orbit radius + + return ( +
+ {/* Central Title (the "Sun") */} + + StudyBuddy + + + {/* Orbiting Characters */} + {images.map((src, index) => { + const total = images.length; + const angle = (360 / total) * index; + const rad = (angle * Math.PI) / 180; + + return ( + + ); + })} +
+ ); +}; + +export default AnimatedCarousel; diff --git a/frontend/src/components/CharacterPickerDialog.tsx b/frontend/src/components/CharacterPickerDialog.tsx new file mode 100644 index 0000000..cf27788 --- /dev/null +++ b/frontend/src/components/CharacterPickerDialog.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { + Dialog, DialogTitle, DialogContent, IconButton, Grid, Card, CardMedia, + CardContent, Typography, Box, Button +} from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; +import type { Character } from './characters'; + +type Props = { + open: boolean; + onClose: () => void; + onSelect: (c: Character) => void; + characters: Character[]; +}; + +export default function CharacterPickerDialog({ open, onClose, onSelect, characters }: Props) { + return ( + + + Select a Character + + + + + + + + {characters.map((char) => ( + + + + + {char.name} + + {char.description} + + + + + + + + ))} + + + + ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 91ef124..39e429f 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -11,6 +11,11 @@ import { useColorMode } from '../theme/ColorModeContext'; import Avatar from '@mui/material/Avatar'; import ProfilePopup from './ProfilePopup'; import { useState } from 'react'; +import { Link as RouterLink } from 'react-router-dom'; +import { Link as MuiLink } from '@mui/material'; + + + // Styled Button for navigation @@ -59,23 +64,24 @@ export default function Header() { > - { /* open menu */ }}> - - - - - StudyBuddy - - + + + StudyBuddy + + + {isDark ? : } - + Pricing + + About + setIsProfileOpen(true)}> {initials} diff --git a/frontend/src/components/MeetOurCharacters.tsx b/frontend/src/components/MeetOurCharacters.tsx new file mode 100644 index 0000000..9aacd6a --- /dev/null +++ b/frontend/src/components/MeetOurCharacters.tsx @@ -0,0 +1,104 @@ +// src/components/MeetOurCharacters.tsx +import React from 'react'; +import { Box, Typography, Card, CardMedia, CardContent, Grid, Divider, Button } from '@mui/material'; + +const characters = [ + { + name: 'Peter Griffin', + image: 'src/character-pics/Peter.PNG', + description: 'Peter Griffin from family guy', + audioSrc: '/voices/peter.mp3', + }, + { + name: 'CaseOh', + image: 'src/character-pics/CaseoH.PNG', + description: 'The eater of worlds', + audioSrc: '/voices/samantha.mp3', + }, + { + name: 'Stewie', + image: 'src/character-pics/stewie.PNG', + description: "Peter's friend", + audioSrc: '/voices/robomax.mp3', + }, + { + name: 'IShowSpeed', + image: 'src/character-pics/speed.PNG', + description: 'The barking streamer with Aura', + audioSrc: '/voices/luna.mp3', + }, +]; + +export default function MeetOurCharacters() { + return ( + + + Meet Our Characters + + + + {characters.map((char, idx) => ( + + + + + + {char.name} + + {char.description} + + + + Voice Snippet + + + + + {/* Example of pushing a button to the bottom */} + + + + + + ))} + + + + + + Community voice clones and custom characters — Coming soon. Stay tuned for voice generation tools! + + + ); +} diff --git a/frontend/src/components/OAuth2RedirectHandler.tsx b/frontend/src/components/OAuth2RedirectHandler.tsx index f46ec57..0dcdece 100644 --- a/frontend/src/components/OAuth2RedirectHandler.tsx +++ b/frontend/src/components/OAuth2RedirectHandler.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; import useUserStore from "../store/userStore"; -import api from "../api/axios"; +import { api } from '../api/axios'; export default function OAuth2RedirectHandler(){ const navigate = useNavigate(); diff --git a/frontend/src/components/ProfilePopup.tsx b/frontend/src/components/ProfilePopup.tsx index 2094e27..50c0542 100644 --- a/frontend/src/components/ProfilePopup.tsx +++ b/frontend/src/components/ProfilePopup.tsx @@ -11,7 +11,7 @@ import { Box } from '@mui/material'; import { toast } from "react-toastify"; -import api from '../api/axios'; +import { api } from '../api/axios'; type ProfilePopupProps = { diff --git a/frontend/src/components/UploadSection.tsx b/frontend/src/components/UploadSection.tsx index 228ea38..f546827 100644 --- a/frontend/src/components/UploadSection.tsx +++ b/frontend/src/components/UploadSection.tsx @@ -14,7 +14,7 @@ import { import DescriptionIcon from "@mui/icons-material/Description"; import UploadFileIcon from "@mui/icons-material/UploadFile"; import "@fontsource/orbitron/700.css"; -import api from "../api/axios"; +import { api } from "../api/axios"; const UploadSection = () => { const [selectedFile, setSelectedFile] = useState(null); diff --git a/frontend/src/components/VideoSummary.tsx b/frontend/src/components/VideoSummary.tsx new file mode 100644 index 0000000..c48cd9e --- /dev/null +++ b/frontend/src/components/VideoSummary.tsx @@ -0,0 +1,101 @@ +// src/components/VideoSummary.tsx +import React, { useState } from 'react'; +import { Box, Button, CircularProgress, Typography, Stack, Chip } from '@mui/material'; +import { pyapi } from '../api/axios'; +import useFolderId from '../hook/useFolderId'; + +interface VideoSummaryProps { + prompt: string; +} + +export default function VideoSummary({ prompt }: VideoSummaryProps) { + const { folderId, loading: folderLoading, error: folderError, refresh } = useFolderId(); + + const [loading, setLoading] = useState(false); + const [videoUrl, setVideoUrl] = useState(null); + const [error, setError] = useState(null); + + const handleGenerate = async () => { + setLoading(true); + setError(null); + setVideoUrl(null); + + try { + if (!folderId) { + throw new Error(folderError || 'Folder ID not available. Open a folder first.'); + } + + // Python expects: { folderID: string, prompt: string } + const resp = await pyapi.post( + '/summarize-into-video', + { folderID: folderId, prompt }, + { responseType: 'blob', withCredentials: true } + ); + + const url = URL.createObjectURL(resp.data); + setVideoUrl(url); + } catch (e: any) { + console.error('Video generation error:', e); + const msg = + e?.response?.data?.detail || + (typeof e?.response?.data === 'string' ? e.response.data : null) || + e?.message || + 'Failed to generate video'; + setError(msg); + } finally { + setLoading(false); + } + }; + + return ( + + + Folder ID: + {folderLoading ? ( + + ) : folderId ? ( + + ) : ( + + )} + + + + {folderError && ( + + {folderError} + + )} + + + + {error && ( + + {error} + + )} + + {videoUrl && ( + + + Your Video Summary + + + )} + + ); +} diff --git a/frontend/src/components/WorkspaceList.tsx b/frontend/src/components/WorkspaceList.tsx index 4124f8d..2011ea8 100644 --- a/frontend/src/components/WorkspaceList.tsx +++ b/frontend/src/components/WorkspaceList.tsx @@ -1,14 +1,13 @@ +// src/components/WorkspaceList.tsx import React from 'react'; import { Grid, Box, Typography } from '@mui/material'; import { styled, alpha, useTheme } from '@mui/material/styles'; import FolderOpenIcon from '@mui/icons-material/FolderOpen'; import AccessTimeIcon from '@mui/icons-material/AccessTime'; -import { useNavigate } from 'react-router-dom'; // 👈 add this -import api from "../api/axios"; +import { useNavigate } from 'react-router-dom'; +import { api } from "../api/axios"; import { toast } from "react-toastify"; - - export interface FolderType { id: number; name: string; @@ -45,29 +44,18 @@ type OpenWorkspaceResponse = { folderId: string; } - - - - export default function WorkspaceList({ folders }: Props) { const theme = useTheme(); const isDark = theme.palette.mode === 'dark'; - const navigate = useNavigate(); //for navigation + const navigate = useNavigate(); const handleOpenWorkspace = async (ws: FolderType) => { try { - // Use ws.id as folderId const response = await api.post(`/folder/open?folderId=${ws.id}`); - console.log("Successfully opened Workspace"); - // You can use ws or response.data.folderId here navigate(`/workbook/${ws.id}`, { state: ws }); } catch (error: any) { const apiError = error.response?.data?.error; - const fallback = "Could not open the workspace"; - const message = typeof apiError === 'string' ? apiError : fallback; - - console.error("Workspace opening error:", message); - + const message = typeof apiError === 'string' ? apiError : "Could not open the workspace"; toast.error(`❌ ${message}`, { position: "top-center", autoClose: 4000, @@ -78,16 +66,21 @@ export default function WorkspaceList({ folders }: Props) { return ( {folders.map(ws => { + // In light mode default to #2196f3; in dark mode fallback to grey const rawColor = ws.color ? ws.color - : (isDark ? '#555555' : '#E3F64D'); + : (isDark ? '#6c6c6cff' : '#8ec4f0ff'); + const headerColor = isDark ? alpha(rawColor, 0.2) : rawColor; + return ( - handleOpenWorkspace(ws)} > + handleOpenWorkspace(ws)} + > {ws.name} diff --git a/frontend/src/components/characters.tsx b/frontend/src/components/characters.tsx new file mode 100644 index 0000000..f2807a6 --- /dev/null +++ b/frontend/src/components/characters.tsx @@ -0,0 +1,33 @@ +export type Character = { + name: string; + image: string; + description: string; + audioSrc?: string; +}; + +export const characters: Character[] = [ + { + name: 'Peter Griffin', + image: 'src/character-pics/Peter.PNG', + description: 'Peter Griffin from family guy', + audioSrc: '/voices/peter.mp3', + }, + { + name: 'CaseOh', + image: 'src/character-pics/CaseoH.PNG', + description: 'The eater of worlds', + audioSrc: '/voices/samantha.mp3', + }, + { + name: 'Stewie', + image: 'src/character-pics/stewie.PNG', + description: "Peter's friend", + audioSrc: '/voices/robomax.mp3', + }, + { + name: 'IShowSpeed', + image: 'src/character-pics/speed.PNG', + description: 'The barking streamer with Aura', + audioSrc: '/voices/luna.mp3', + }, +]; diff --git a/frontend/src/components/loginCard.tsx b/frontend/src/components/loginCard.tsx index d085689..46aec26 100644 --- a/frontend/src/components/loginCard.tsx +++ b/frontend/src/components/loginCard.tsx @@ -11,7 +11,7 @@ import { } from "@mui/material" import { Google as GoogleIcon } from "@mui/icons-material"; import { useTheme } from "@mui/material/styles"; -import api from "../api/axios"; +import { api } from '../api/axios'; import { useState } from "react"; import { toast } from "react-toastify"; import useUserStore from '../store/userStore'; diff --git a/frontend/src/components/signupCard.tsx b/frontend/src/components/signupCard.tsx index d9f225c..e042eba 100644 --- a/frontend/src/components/signupCard.tsx +++ b/frontend/src/components/signupCard.tsx @@ -10,7 +10,7 @@ import { } from "@mui/material" import { Google as GoogleIcon } from "@mui/icons-material"; import { useTheme } from "@mui/material/styles"; -import api from "../api/axios"; +import { api } from "../api/axios"; import { useState } from "react"; import { toast } from "react-toastify"; import useUserStore from '../store/userStore'; diff --git a/frontend/src/hook/WorkbookSessionManager.tsx b/frontend/src/hook/WorkbookSessionManager.tsx new file mode 100644 index 0000000..b95fe3e --- /dev/null +++ b/frontend/src/hook/WorkbookSessionManager.tsx @@ -0,0 +1,88 @@ +import { useEffect, useRef } from "react"; +import { useLocation, matchPath } from "react-router-dom"; +import { api } from "../api/axios"; + + +// ---------------------------------------------------- +// 🔒 WorkbookSessionManager +// This component tracks when the user ENTERS and EXITS +// the /workbook/:folderName route. +// When exiting, it calls the backend API to cleanup +// the session (e.g., delete cookies, unlock folder, etc). +// +// It also handles tab/browser close using sendBeacon. +// This component is invisible and should be mounted globally. +// ---------------------------------------------------- + + +const exitFolder = async () => { + try { + console.log("📡 Calling API: /folder/exitFolder"); + await api.post("/folder/exitFolder"); + console.log("✅ exitFolder() called successfully"); + } catch (error) { + console.error("❌ Error exiting folder", error); + } +}; + +export default function WorkbookSessionManager() { + const location = useLocation(); + const prevLocation = useRef(location); + const activated = useRef(false); // ensures we don’t run on initial mount + const timeoutRef = useRef | null>(null); // manage debounce timer + + + // ----------------------------------------- + // 📍 Detect Route Changes + // Checks if the user navigated away from /workbook/:folderName + // If yes, and we're not on first load, trigger exitFolder(). + // Adds a small delay (150ms) to let routing/rendering stabilize. + // ----------------------------------------- + useEffect(() => { + // Clear any previously scheduled execution + if (timeoutRef.current) clearTimeout(timeoutRef.current); + + timeoutRef.current = setTimeout(() => { + const from = prevLocation.current.pathname; + const to = location.pathname; + + const wasWorkbook = !!matchPath("/workbook/:folderName", from); + const nowWorkbook = !!matchPath("/workbook/:folderName", to); + + console.log("📍 Route changed FROM:", from, "TO:", to); + console.log("🧠 wasWorkbook:", wasWorkbook, "nowWorkbook:", nowWorkbook); + + if (activated.current && wasWorkbook && !nowWorkbook) { + console.log("🔔 Detected exit from workbook. Triggering exitFolder()"); + exitFolder(); + } + + prevLocation.current = location; + activated.current = true; + }, 150); // Adjust delay here (ms) + + return () => { + // Clean up timer if effect is re-triggered early + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, [location]); + + + // ----------------------------------------- + // 🧳 Handle Tab Close / Refresh + // When the user closes or reloads the tab, + // use sendBeacon to tell the backend to exit the folder. + // This runs only once (on component mount). + // ----------------------------------------- + useEffect(() => { + const handleBeforeUnload = () => { + console.log("🧹 Tab is closing or refreshing → sendBeacon exitFolder()"); + navigator.sendBeacon?.("/folder/exitFolder"); + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, []); + + return null; +} diff --git a/frontend/src/hook/useFolderId.tsx b/frontend/src/hook/useFolderId.tsx new file mode 100644 index 0000000..cb102fb --- /dev/null +++ b/frontend/src/hook/useFolderId.tsx @@ -0,0 +1,37 @@ +// src/hooks/useFolderId.ts +import { useCallback, useEffect, useState } from 'react'; +import { api } from '../api/axios'; + +type FolderInfo = { id: string; name?: string }; + +export default function useFolderId() { + const [folderId, setFolderId] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const fetchFolderId = useCallback(async () => { + setLoading(true); + setError(null); + try { + // path is /folder/listCurrentFolderId + const res = await api.get('/folder/listCurrentFolderId', { + withCredentials: true, // ensure cookies are sent (jwt + folderSession) + }); + if (!res.data?.id) throw new Error('Response missing "id".'); + setFolderId(String(res.data.id)); + } catch (e: any) { + const msg = + e?.response?.data || e?.message || 'Failed to resolve current folder ID.'; + setError(typeof msg === 'string' ? msg : 'Failed to resolve current folder ID.'); + setFolderId(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchFolderId(); + }, [fetchFolderId]); + + return { folderId, loading, error, refresh: fetchFolderId }; +} diff --git a/frontend/src/pages/Homepage.tsx b/frontend/src/pages/Homepage.tsx index 8fa6a34..cf49ec7 100644 --- a/frontend/src/pages/Homepage.tsx +++ b/frontend/src/pages/Homepage.tsx @@ -1,12 +1,13 @@ import React, { useState, useEffect } from 'react'; import { Container, Typography, Box } from '@mui/material'; import type { FolderType } from '../components/WorkspaceList'; -import api from '../api/axios'; +import { api } from '../api/axios'; import FeatureSection from '../components/FeatureSection'; import WorkspaceList from '../components/WorkspaceList'; import CreateWorkspaceCard from '../components/CreateWorkspaceCard'; import CreateFolderDialog from '../components/CreateFolderDialog'; +import MeetOurCharacters from '../components/MeetOurCharacters'; export default function Homepage() { const [folders, setFolders] = useState([]); @@ -74,6 +75,10 @@ export default function Homepage() { setIsDialogOpen(true)} /> + + + + ); } diff --git a/frontend/src/pages/LandingPage.tsx b/frontend/src/pages/LandingPage.tsx new file mode 100644 index 0000000..9a891bf --- /dev/null +++ b/frontend/src/pages/LandingPage.tsx @@ -0,0 +1,224 @@ +// src/pages/LandingPage.tsx +import React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + AppBar, + Toolbar, + Typography, + Button, + Container, + Box, + Grid, + Card, + CardContent, + IconButton, + CssBaseline, +} from '@mui/material'; +import { + VideoLibrary, + Person, + SmartToy, + EmojiEmotions, +} from '@mui/icons-material'; +import useUserStore from '../store/userStore'; + +const features = [ + { + icon: , + title: 'AI‑Driven Scripts', + desc: 'Convert any topic into a 2 min reel with your favorite characters.', + }, + { + icon: , + title: 'TikTok‑Style Clips', + desc: 'Engage through short, catchy videos optimized for Gen Z attention spans.', + }, + { + icon: , + title: 'Personalized Avatars', + desc: 'Learn from “Peter”, “Stewie”, or create your own AI tutor persona.', + }, + { + icon: , + title: 'Fun & Gamified', + desc: 'Earn streaks, badges, and share progress with your study buddies.', + }, +]; + +export default function LandingPage() { + const currentYear = new Date().getFullYear(); + const navigate = useNavigate(); + const user = useUserStore((s) => s.user); + + const handlePricing = () => { + navigate('/pricing'); + }; + + const handleGetStarted = () => { + if (user) { + navigate('/app'); // Authenticated homepage route + } else { + navigate('/authentication'); // login route + } + }; + + return ( + + + + {/* Navigation */} + {!localStorage.getItem('user') && ( + + + StudyBuddy + + + + + + + )} + + {/* Hero */} + + + + + + Learn Smarter, Not Harder + + + 2‑minute AI reels, your favorite characters, and a gamified study journey. + + + + + + + + {/* Features */} + + + Why StudyBuddy? + + + + {features.map((f, idx) => ( + + + + + {f.icon} + + + {f.title} + + + {f.desc} + + + + + ))} + + + + {/* Call‑to‑Action */} + + + + + + Ready to supercharge your studies? + + + + + + + + + + {/* Footer */} + + + + © {currentYear} StudyBuddy. All rights reserved. + + + + + ); +} diff --git a/frontend/src/pages/Pricing.tsx b/frontend/src/pages/Pricing.tsx index 869e7bd..118a05b 100644 --- a/frontend/src/pages/Pricing.tsx +++ b/frontend/src/pages/Pricing.tsx @@ -25,30 +25,30 @@ type Plan = { // Data for our plans const plans: Plan[] = [ { - name: 'Individual', + name: 'Personal', price: 0, period: '/month', - description: 'Best option for personal use', + description: 'Best option for limited use', features: [ - 'Up to 5 video generations/week', - '1,000,000 tokens for summarization /month', - 'Free new voice packs', + '1 video generation and summary per day', + 'Gain access to all community character voices', 'Up to 2 GB file storage', + 'Access to standard features' ], - buttonText: 'Join the waitlist', + buttonText: 'Coming Soon!', }, { - name: 'Pro', + name: 'Aura Farmer', price: 20, period: '/month', - description: 'Best option for unlimited usage!', + description: 'For unlimited usage!', features: [ - 'Unlimited video generations', - 'Unlimited summarization usage', - 'Request new voice packs', + 'Unlimited video generations & summaries', + 'Create your own custom character voices', 'Up to 50 GB file storage', + 'Early access to new features' ], - buttonText: 'Join the waitlist', + buttonText: 'Coming Soon!', featured: true, }, ]; diff --git a/frontend/src/pages/Workbook.tsx b/frontend/src/pages/Workbook.tsx index 38f17a4..506007c 100644 --- a/frontend/src/pages/Workbook.tsx +++ b/frontend/src/pages/Workbook.tsx @@ -1,132 +1,244 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useRef, useEffect } from 'react'; +import VideoSummary from '../components/VideoSummary'; import { Box, - Tabs, - Tab, - Typography, Container, - Paper -} from "@mui/material"; -import { useParams, useLocation } from "react-router-dom"; -import "@fontsource/orbitron/700.css"; -import { motion } from "framer-motion"; -import UploadSection from "../components/UploadSection"; -import VoiceGenerationSection from "../components/VoiceGenerationSection"; -import type { FolderType } from "../components/WorkspaceList"; -import api from "../api/axios"; // adjust path if needed - - -const tabs = ["UPLOAD", "AI SUMMARY", "AI CHAT", "VOICE GENERATION"]; - -const WorkbookPage = () => { - const [activeTab, setActiveTab] = useState(0); - const { id } = useParams(); // fallback id from URL - const location = useLocation(); - const folder = location.state as FolderType | undefined; - - const [folderName, setFolderName] = useState(""); - - useEffect(() => { - if (folder?.name) { - setFolderName(folder.name); - } else if (id) { - // fallback fetch by id if location.state is undefined (direct URL access) - // TODO: Replace this with actual API call if needed - // Example: - // fetch(`/api/folder/${id}`).then(res => res.json()).then(data => setFolderName(data.name)); - setFolderName(`Workspace #${id}`); // Temporary fallback label - } - }, [folder, id]); - - const exitFolder = async () => { + Typography, + Button, + IconButton, + TextField, + Stack, + InputAdornment, + List, + ListItem, + ListItemIcon, + ListItemText, + Paper, + Divider, + useTheme, + Chip +} from '@mui/material'; +import { + AttachFile, + Description as DescriptionIcon, +} from '@mui/icons-material'; +import { api } from '../api/axios'; +import CharacterPickerDialog from '../components/CharacterPickerDialog'; +import { characters, type Character } from '../components/characters'; + +export default function DiscoverPage() { + const theme = useTheme(); + const isDark = theme.palette.mode === 'dark'; + + const [prompt, setPrompt] = useState(''); + const fileInputRef = useRef(null); + + // --- Character picker state --- + const [isCharDialogOpen, setIsCharDialogOpen] = useState(false); + const [selectedCharacter, setSelectedCharacter] = useState(null); + + // --- Upload logic states --- + const [selectedFile, setSelectedFile] = useState(null); + const [uploading, setUploading] = useState(false); + const [uploadResult, setUploadResult] = useState(null); + const [files, setFiles] = useState<{ id: string; name: string }[]>([]); + + // Fetch the list of uploaded files + const fetchFiles = async () => { try { - await api.post("/folder/exitFolder"); - // Optionally log or handle response - } catch (error) { - // Optionally log or toast - console.error("Error exiting folder", error); + const resp = await api.get<{ id: string; name: string }[]>('/file/listFiles'); + setFiles(resp.data); + } catch { + setFiles([]); } }; - useEffect(() => { - const handleBeforeUnload = () => { - navigator.sendBeacon && navigator.sendBeacon("/folder/exitFolder") - } - window.addEventListener("beforeunload", handleBeforeUnload); - - return () => { - window.removeEventListener("beforeunload",handleBeforeUnload); - console.log("unloading function") - // exitFolder(); - - } - - }, []); - - + useEffect(() => { + fetchFiles(); + }, []); + // When user clicks the paper-clip + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; - const handleTabChange = (_: React.SyntheticEvent, newValue: number) => { - setActiveTab(newValue); + // When a file is chosen + const handleFileChange = (e: React.ChangeEvent) => { + if (e.target.files?.[0]) { + setSelectedFile(e.target.files[0]); + setUploadResult(null); + } }; - const renderTabContent = () => { - switch (activeTab) { - case 0: - return ; - case 1: - return AI Summary Component/Section here; - case 2: - return AI Chat Component/Section here; - case 3: - return ; - default: - return null; + // POST file to backend + const handleUpload = async () => { + if (!selectedFile) return; + setUploading(true); + setUploadResult(null); + + try { + const formData = new FormData(); + formData.append('file', selectedFile); + const resp = await api.post('/file/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + withCredentials: true, + }); + setUploadResult(resp.data); + setSelectedFile(null); + await fetchFiles(); // refresh list + } catch (err: any) { + setUploadResult(err?.response?.data || 'Upload failed'); + } finally { + setUploading(false); } }; return ( - - - theme.palette.mode === "dark" ? "#D1A4FF" : "#6A1B9A", - textShadow: - "0 0 10px rgba(209, 164, 255, 0.7), 0 0 20px rgba(186, 104, 200, 0.5)" - }} - > - {folderName || "Folder Name"} - - - - {tabs.map((label, index) => ( - - ))} - - - - {renderTabContent()} - - - ); -}; + + + {/* Heading */} + + What are you learning today? + + + {/* Character selector */} + + + + + {/* Show chosen character summary (if any) */} + + + -export default WorkbookPage; + {/* Hidden file input */} + + + {/* Prompt + upload adornment */} + setPrompt(e.target.value)} + InputProps={{ + startAdornment: ( + + + + + + ), + }} + sx={{ + mb: 2, + '& .MuiOutlinedInput-root': { + backgroundColor: isDark ? 'grey.900' : 'common.white', + borderRadius: '16px', + '& .MuiOutlinedInput-notchedOutline': { + borderRadius: '16px', + }, + }, + }} + /> + + + + {/* Show chosen file & upload button */} + {selectedFile && ( + + + 📄 {selectedFile.name} + + + + )} + + {/* Upload result message */} + {uploadResult && ( + + {uploadResult} + + )} + + {/* List of uploaded files */} + + + Your Files + + + {files.length === 0 ? ( + + No files uploaded yet. + + ) : ( + + {files.map((f) => ( + + + + + + + ))} + + )} + + + {/* Character dialog */} + setIsCharDialogOpen(false)} + onSelect={(c) => setSelectedCharacter(c)} + characters={characters} + /> + + ); +} \ No newline at end of file diff --git a/frontend/src/service/WorkbookSessionManager.tsx b/frontend/src/service/WorkbookSessionManager.tsx deleted file mode 100644 index 460ae0c..0000000 --- a/frontend/src/service/WorkbookSessionManager.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useEffect, useRef } from "react"; -import { useLocation, matchPath } from "react-router-dom"; -import api from "../api/axios"; - -// THE POINT OF THIS FILE IS TO HANDLE THE EXITING OF THE FOLDER ACROSS THE WHOLE APP AS IT DEALS WITH COOKIES AND EVERYTHING - - -const exitFolder = async () => { - try { - await api.post("/folder/exitFolder"); - } catch (error) { - console.error("Error exiting folder", error); - } -}; - -export default function WorkbookSessionManager() { - const location = useLocation(); - const prevLocation = useRef(location); - const activated = useRef(false); // ⬅️ Track if active after delay - - useEffect(() => { - const from = prevLocation.current.pathname; - const to = location.pathname; - - const wasWorkbook = !!matchPath("/workbook/:folderName", from); - const nowWorkbook = !!matchPath("/workbook/:folderName", to); - - - // Only trigger after activation - if (activated.current && wasWorkbook && !nowWorkbook) { - exitFolder(); - console.log(wasWorkbook); - console.log(nowWorkbook); - console.log("From:", from, "To:", to, "wasWorkbook:", wasWorkbook, "nowWorkbook:", nowWorkbook, "activated:", activated.current); - - - } - prevLocation.current = location; - }, [location]); - - // Handle browser/tab close - useEffect(() => { - const handleBeforeUnload = () => { - navigator.sendBeacon && navigator.sendBeacon("/folder/exitFolder"); - }; - window.addEventListener("beforeunload", handleBeforeUnload); - exitFolder(); - - return () => window.removeEventListener("beforeunload", handleBeforeUnload); - }, []); - - return null; -} diff --git a/frontend/src/theme/theme.ts b/frontend/src/theme/theme.ts index 1c383f6..aba6773 100644 --- a/frontend/src/theme/theme.ts +++ b/frontend/src/theme/theme.ts @@ -13,7 +13,7 @@ export const getDesignTokens = (mode: PaletteMode): ThemeOptions => ({ }, } : { - background: { default: "#101010" }, + background: { default: "#1e1b1bff" }, primary: { main: "#8a2be2", // fallback purple }, diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 8b0f57b..e22436e 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -4,4 +4,10 @@ import react from '@vitejs/plugin-react' // https://vite.dev/config/ export default defineConfig({ plugins: [react()], + server: { + hmr: { + overlay: false, + }, +} + }) diff --git a/inshallah.md b/inshallah.md new file mode 100644 index 0000000..c20ef30 --- /dev/null +++ b/inshallah.md @@ -0,0 +1 @@ +newsletter diff --git a/objectives.md b/objectives.md new file mode 100644 index 0000000..8a8d4bb --- /dev/null +++ b/objectives.md @@ -0,0 +1,64 @@ +Tasks left to do: + +bugs: => SNEHAL RAY => 1 DAY +1. cookie bugs ✅ +2. oauth bug + + +3. about frontend page publicly accessible / HOMEPAGE => AKHIL => 1 DAY ✅ + +4. animation of characters in a carousel => SNEHAL => VIBE CODE EITHER 10 MINS OR 2HRS + +5. workbook frontend ui changes (NEED approval) => AKHIL => 1 DAY ✅ + +VIDEO GENERATION: => snehal: 3hrs; akhil : 1hr + +1. Video has no expiry => snehal +2. Video stored in AWS => snehal +3. change ui of the vid gen => akhil + + +=> OPEN AI SUMMARY OF THE DOC (need approval) => Akhil => 3-4hrs ✅ + +=> postgres hosted on aws => Snehal => 5hrs ✅ + +=> image character pic => s3 integration => Snehal => 2hrs => elevenlabid is the key/folder name ✅ + +=> stich image to video dynamically => Akhil (dependent on me) => 1hr + +=> SELECT VIDEO AND character template in the work space => AKHIL => 2hr ✅ + +============ + +SELECTION: + +1. DOWNLOAD background VIDEOS as templates => 1hr +2. download roster voices data => 1day ✅ + + +============ + +ROSTER: + +1. Peter Griffin +2. CaseOh +3. Master Oogway +4. Rahul Gandhi (Need to change) +5. Speed (need to change) +6. Sydney Sweeney + +======================== +VIDEO TEMPLATE: + +1. SUBWAY SURFER +2. GTA RACES 2X +3. MINECRAFT 3X + + + +OVERALL ESTIMATE: +SNEHAL: 2.5 DAYS +AKHIL: 4 DAYS + + +COMPLETION: 2ND AUGUST DEPLOYMENT