diff --git a/apps/dashboard/tsconfig.app.json b/apps/dashboard/tsconfig.app.json index a763ae3..2ab1cb3 100644 --- a/apps/dashboard/tsconfig.app.json +++ b/apps/dashboard/tsconfig.app.json @@ -19,7 +19,10 @@ /* Path aliases */ "paths": { "@app/ui": ["../../shared/ui/src/index.ts"], - "@app/ui/*": ["../../shared/ui/src/*"] + "@app/ui/*": ["../../shared/ui/src/*"], + "@convex-dev/auth/react": [ + "./node_modules/@convex-dev/auth/dist/react/index.d.ts" + ] }, /* Linting */ diff --git a/apps/extension/manifest.json b/apps/extension/manifest.json new file mode 100644 index 0000000..26f1c10 --- /dev/null +++ b/apps/extension/manifest.json @@ -0,0 +1,32 @@ +{ + "manifest_version": 3, + "name": "Cornell Loop", + "version": "0.0.1", + "action": { + "default_title": "Cornell Loop", + "default_icon": { + "16": "popup_icon.png", + "32": "popup_icon.png", + "48": "popup_icon.png", + "128": "popup_icon.png" + } + }, + "icons": { + "16": "popup_icon.png", + "32": "popup_icon.png", + "48": "popup_icon.png", + "128": "popup_icon.png" + }, + "background": { + "service_worker": "src/background.ts", + "type": "module" + }, + "content_scripts": [ + { + "matches": ["https://mail.google.com/*", "https://calendar.google.com/*"], + "js": ["src/content.tsx"], + "run_at": "document_idle" + } + ], + "permissions": ["storage", "identity", "tabs"] +} diff --git a/apps/extension/package.json b/apps/extension/package.json new file mode 100644 index 0000000..44474de --- /dev/null +++ b/apps/extension/package.json @@ -0,0 +1,40 @@ +{ + "name": "@app/extension", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite build --watch", + "build": "vite build", + "lint": "eslint .", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@app/ui": "workspace:*", + "@auth/core": "0.37.0", + "@convex-dev/auth": "^0.0.91", + "convex": "^1.32.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "tailwindcss": "^4.2.2", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "@types/chrome": "^0.0.320", + "@types/webextension-polyfill": "^0.10.0", + "vite": "^7.3.1", + "vite-plugin-svgr": "^5.2.0", + "vite-plugin-web-extension": "^4.1.1" + } +} diff --git a/apps/extension/public/floating_icon.svg b/apps/extension/public/floating_icon.svg new file mode 100644 index 0000000..bfde99a --- /dev/null +++ b/apps/extension/public/floating_icon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/extension/public/loop_logo.png b/apps/extension/public/loop_logo.png new file mode 100644 index 0000000..23cccf3 Binary files /dev/null and b/apps/extension/public/loop_logo.png differ diff --git a/apps/extension/public/popup_icon.png b/apps/extension/public/popup_icon.png new file mode 100644 index 0000000..db93ef6 Binary files /dev/null and b/apps/extension/public/popup_icon.png differ diff --git a/apps/extension/src/App.tsx b/apps/extension/src/App.tsx new file mode 100644 index 0000000..63189b1 --- /dev/null +++ b/apps/extension/src/App.tsx @@ -0,0 +1,175 @@ +import { useState } from "react"; +import { Button } from "@app/ui"; +import SearchHeader from "./components/SearchHeader"; +import FeedView from "./components/FeedView"; +import BookmarkView from "./components/BookmarkView"; +import SearchView from "./components/SearchView"; +import OriginalEmailView from "./components/OriginalEmailView"; +import type { EventItem } from "./data/types"; +import { openExternalUrl } from "./utils/linkUtils"; + +type View = "feed" | "bookmarks" | "search" | "email"; + +export type PageContext = "gmail" | "gcal"; + +export interface AppProps { + onClose?: () => void; + pageContext?: PageContext; + /** Called by BookmarkView when the user hovers a bookmark card on GCal. */ + onPreviewSlot?: (event: EventItem | null) => void; +} + +const DASHBOARD_URL = + (import.meta.env.VITE_DASHBOARD_URL as string | undefined) ?? + "https://cornellloop.com"; + +export default function App({ + onClose, + pageContext = "gmail", + onPreviewSlot, +}: AppProps) { + const [view, setView] = useState("feed"); + const [activeTab, setActiveTab] = useState<"feed" | "bookmarks">("feed"); + const [searchQuery, setSearchQuery] = useState(""); + + // ── Bookmark state ───────────────────────────────────────────────────── + const [bookmarkedIds, setBookmarkedIds] = useState>(new Set()); + + const toggleBookmark = (id: string) => { + setBookmarkedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + // ── Email view ───────────────────────────────────────────────────────── + const [emailEvent, setEmailEvent] = useState(null); + + const handleEmailView = (event: EventItem) => { + setEmailEvent(event); + setView("email"); + }; + + // ── Navigation ───────────────────────────────────────────────────────── + const isSearchMode = view === "search" || view === "email"; + + const handleTabChange = (tab: string) => { + const t = tab as "feed" | "bookmarks"; + setActiveTab(t); + setView(t); + setEmailEvent(null); + }; + + const handleSearchFocus = () => setView("search"); + + const handleSearchChange = (q: string) => { + setSearchQuery(q); + setView("search"); + }; + + const handleSearchClear = () => setSearchQuery(""); + + const handleBack = () => { + setSearchQuery(""); + setEmailEvent(null); + setView(activeTab); + }; + + /** Populate the search bar when the user clicks a popular search term. */ + const handleSearchSelect = (term: string) => { + setSearchQuery(term); + setView("search"); + }; + + return ( +
+ {/* ── Sticky header ── */} +
+ +
+ + {/* ── Main content: search uses pinned footer CTA; other views scroll with CTA inline ── */} + {view === "search" ? ( +
+
+ +
+ {/* Pinned to bottom of panel while search content scrolls */} +
+ +
+
+ ) : ( +
+ {view === "feed" && ( + + )} + + {view === "bookmarks" && ( + + )} + + {view === "email" && } + + {/* CTA — opens dashboard in a new tab */} +
+ +
+
+ )} +
+ ); +} diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts new file mode 100644 index 0000000..909e5d5 --- /dev/null +++ b/apps/extension/src/background.ts @@ -0,0 +1,2 @@ +// No side panel logic needed — UI is injected via content script. +export {}; diff --git a/apps/extension/src/components/BookmarkCard.tsx b/apps/extension/src/components/BookmarkCard.tsx new file mode 100644 index 0000000..aa0b846 --- /dev/null +++ b/apps/extension/src/components/BookmarkCard.tsx @@ -0,0 +1,273 @@ +/** + * BookmarkCard — Extension UI + * + * Source: Figma "Incubator-design-file" › Popup › Bookmarks view + * Node: 554:5543 (single card instance) + * + * Layout: + * ┌───────────────────────────────────────┐ + * │ [avatar] Org name │ ← org header + * │ ──────────────────────────────────── │ + * │ [DateBadge] Title [bookmark] │ ← event row + * │ Subtitle line 1 │ + * │ Subtitle line 2 │ + * │ [Tag] [Tag] │ ← tags (optional) + * │ ───────────────────────────────── │ + * │ [ RSVP / Apply ] [ Add to Calendar ] │ ← actions (each conditional) + * └───────────────────────────────────────┘ + * + * Subtitle variants (Figma annotations): + * string[] → event with time + location: ['4:00–5:30 pm', 'Hollister Hall 312'] + * string → informative summary or edge-case "Click to see original email" + * undefined → no subtitle shown + * + * When `onSubtitleClick` is provided (edge-case event), clicking the subtitle + * opens OriginalEmailView. The cursor changes to pointer to signal interactivity. + */ + +import type { ComponentPropsWithoutRef } from "react"; +import { DateBadge, Tag, Button } from "@app/ui"; +import type { ThumbnailVariant } from "@app/ui"; +import BookmarkFilledIcon from "@app/ui/assets/bookmark-filled.svg?react"; + +// ── Typography shared strings ────────────────────────────────────────────── + +const BODY2_SEMIBOLD = + "font-[family-name:var(--font-body)] font-semibold " + + "text-[length:var(--font-size-body2)] leading-[var(--line-height-body2)] " + + "tracking-[var(--letter-spacing-body2)]"; + +const BODY3 = + "font-[family-name:var(--font-body)] font-normal " + + "text-[length:var(--font-size-body3)] leading-[var(--line-height-body3)] " + + "tracking-[var(--letter-spacing-body3)]"; + +// ── Props ────────────────────────────────────────────────────────────────── + +export interface BookmarkCardProps extends ComponentPropsWithoutRef<"div"> { + /** Organisation name shown in the card header. */ + orgName: string; + /** URL for the org's circular avatar. Falls back to an initials badge. */ + orgAvatarUrl?: string; + /** Controls the DateBadge thumbnail style. Defaults to "date". */ + thumbnailVariant?: ThumbnailVariant; + /** Day-of-month for the "date" thumbnail (e.g. 24). */ + day?: number | string; + /** Abbreviated month for the "date" thumbnail (e.g. "Apr"). */ + month?: string; + /** Event title — DM Sans SemiBold 14 px, Neutral/900. */ + title: string; + /** + * Subtitle shown below the title. + * string[] → multiple lines (e.g. time + location) + * string → single line (informative summary or edge-case message) + */ + subtitle?: string | string[]; + /** + * When provided, clicking the subtitle triggers this handler. + * Used for edge-case events where the subtitle reads "Click to see original email". + */ + onSubtitleClick?: () => void; + /** Neutral/200 category tags shown beneath the event row. */ + tags?: string[]; + /** + * Primary action button (RSVP / Apply / Register). + * Figma annotation: "appears only if there's a primary link". + * Prefer `links[]` → `getPrimaryLink()` to derive this from EventItem. + */ + primaryAction?: { label: string; onClick: () => void }; + /** + * When provided, an Add to Calendar button is rendered. + * Figma annotation: "appears only when there's specific date and time". + */ + onAddToCalendar?: () => void; + /** Called when the filled bookmark icon is pressed to remove the save. */ + onUnbookmark?: () => void; + /** + * Hover handlers wired to GCal grid highlight (passed from BookmarkView). + * noop when not on a GCal page. + */ + onPreviewEnter?: () => void; + onPreviewLeave?: () => void; +} + +// ── Component ────────────────────────────────────────────────────────────── + +export function BookmarkCard({ + orgName, + orgAvatarUrl, + thumbnailVariant = "date", + day, + month, + title, + subtitle, + onSubtitleClick, + tags, + primaryAction, + onAddToCalendar, + onUnbookmark, + onPreviewEnter, + onPreviewLeave, + className, + ...rest +}: BookmarkCardProps) { + const subtitleLines: string[] = + subtitle == null ? [] : Array.isArray(subtitle) ? subtitle : [subtitle]; + + const hasActions = primaryAction != null || onAddToCalendar != null; + const subtitleClickable = onSubtitleClick != null && subtitleLines.length > 0; + + return ( +
+ {/* ── Org header ── */} +
+ {/* 32 px avatar circle */} +
+ {orgAvatarUrl ? ( + {orgName} + ) : ( + + {orgName.charAt(0).toUpperCase()} + + )} +
+ + {/* Org name — DM Sans SemiBold 14 px, Neutral/700 */} + + {orgName} + +
+ + {/* ── Middle: event row + tags ── */} +
+ {/* Event row — DateBadge + text + filled bookmark */} +
+ + + {/* Title + subtitle */} +
+

+ {title} +

+ + {subtitleLines.length > 0 && ( +
e.key === "Enter" && onSubtitleClick?.() + : undefined + } + > + {subtitleLines.map((line, i) => ( +

+ {line} +

+ ))} +
+ )} +
+ + {/* Bookmark — always filled (orange) since this card is in the saved list */} + +
+ + {/* Tags */} + {tags && tags.length > 0 && ( +
+ {tags.map((label) => ( + + {label} + + ))} +
+ )} +
+ + {/* ── Action buttons ── */} + {hasActions && ( +
+ {primaryAction && ( + + )} + {onAddToCalendar && ( + + )} +
+ )} +
+ ); +} diff --git a/apps/extension/src/components/BookmarkView.tsx b/apps/extension/src/components/BookmarkView.tsx new file mode 100644 index 0000000..7676599 --- /dev/null +++ b/apps/extension/src/components/BookmarkView.tsx @@ -0,0 +1,195 @@ +/** + * BookmarkView — "Your Bookmarks" + * + * Shows events the user has saved. Tag-strip supports: + * • Click to toggle filter (OR match across active tags) + * • + to add a custom tag + * • Pencil → edit mode → × to delete a tag + * + * On Google Calendar (pageContext === "gcal"): + * • Subheader "Hover on the events to preview time on your calendar" is shown + * • Hovering a card calls onPreviewSlot to inject the orange-border highlight + * into the actual GCal grid via the content-script bridge. + */ + +import { useState } from "react"; +import type { EventItem } from "../data/types"; +import type { PageContext } from "../App"; +import { useAllEvents } from "../data/useEvents"; +import { + getPrimaryLink, + getLinkLabel, + openExternalUrl, +} from "../utils/linkUtils"; +import { buildGCalUrl } from "../utils/calendarUtils"; +import { removeSlotPreview } from "../gcalHighlight"; +import { BookmarkCard } from "./BookmarkCard"; +import { SortByTags } from "./SortByTags"; + +// ── Typography ───────────────────────────────────────────────────────────── + +// Figma: Inter Regular 16px, #5f5f5f, tracking -0.176px, leading 1.5 +const SORT_LABEL = + "font-[family-name:var(--font-body)] font-normal " + + "text-[1rem] leading-[1.5] tracking-[-0.176px] " + + "text-[#5f5f5f] whitespace-nowrap"; + +// Figma: Inter SemiBold 20px, #5f5f5f, tracking -0.22px, leading 1.5 +const SECTION_HEADING = + "font-[family-name:var(--font-body)] font-semibold " + + "text-[1.25rem] leading-[1.5] tracking-[-0.22px] " + + "text-[#5f5f5f] whitespace-nowrap"; + +// GCal subheader — Inter Regular 14px, #5f5f5f (Figma annotation) +const GCAL_SUBHEADER = + "font-[family-name:var(--font-body)] font-normal " + + "text-[length:var(--font-size-body2)] leading-[1.5] " + + "text-[#5f5f5f]"; + +const INITIAL_SORT_TAGS = [ + "Internships", + "Early career", + "Tech", + "Mentorship", + "Just for fun", +]; + +// ── Props ─────────────────────────────────────────────────────────────────── + +interface BookmarkViewProps { + bookmarkedIds: Set; + onBookmark: (id: string) => void; + onEmailView: (event: EventItem) => void; + pageContext: PageContext; + onPreviewSlot?: (event: EventItem | null) => void; +} + +// ── Component ──────────────────────────────────────────────────────────────── + +export default function BookmarkView({ + bookmarkedIds, + onBookmark, + onEmailView, + pageContext, + onPreviewSlot, +}: BookmarkViewProps) { + const allEvents = useAllEvents(); + + // Derive bookmarked events from the full list + const bookmarkedEvents = allEvents.filter((e) => bookmarkedIds.has(e.id)); + + // ── Tag filter state ────────────────────────────────────────────────── + const [availableTags, setAvailableTags] = useState(INITIAL_SORT_TAGS); + const [activeTags, setActiveTags] = useState([]); + + const handleTagToggle = (tag: string) => { + setActiveTags((prev) => + prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag], + ); + }; + + const handleTagAdd = (tag: string) => { + setAvailableTags((prev) => [...prev, tag]); + setActiveTags((prev) => [...prev, tag]); + }; + + const handleTagRemove = (tag: string) => { + setAvailableTags((prev) => prev.filter((t) => t !== tag)); + setActiveTags((prev) => prev.filter((t) => t !== tag)); + }; + + // Filter: show all when no active tags; OR match otherwise + const filteredEvents = + activeTags.length === 0 + ? bookmarkedEvents + : bookmarkedEvents.filter((e) => + activeTags.some((tag) => e.tags.includes(tag)), + ); + + return ( +
+ {/* ── Sort by ── */} +
+

Sort by

+ +
+ + {/* ── Your Bookmarks ── */} +
+
+

Your Bookmarks

+ + {/* GCal-only subheader */} + {pageContext === "gcal" && ( +

+ Hover on the events to preview time on your calendar +

+ )} +
+ + {filteredEvents.length === 0 && ( +

+ {bookmarkedEvents.length === 0 + ? "Bookmark events from the Feed to save them here." + : "No bookmarks match the selected filters."} +

+ )} + +
+ {filteredEvents.map((event) => { + const primaryLink = getPrimaryLink(event); + const primaryAction = primaryLink + ? { + label: getLinkLabel(primaryLink), + onClick: () => openExternalUrl(primaryLink.url), + } + : undefined; + + const onAddToCalendar = event.calendarEvent + ? () => { + removeSlotPreview(); + // Full GCal event editor (new tab) — pre-filled via URL template + openExternalUrl(buildGCalUrl(event.calendarEvent!)); + } + : undefined; + + return ( + onEmailView(event) : undefined + } + tags={event.tags} + primaryAction={primaryAction} + onAddToCalendar={onAddToCalendar} + onUnbookmark={() => onBookmark(event.id)} + onPreviewEnter={ + pageContext === "gcal" && event.calendarEvent + ? () => onPreviewSlot?.(event) + : undefined + } + onPreviewLeave={ + pageContext === "gcal" && event.calendarEvent + ? () => onPreviewSlot?.(null) + : undefined + } + /> + ); + })} +
+
+
+ ); +} diff --git a/apps/extension/src/components/FeedView.tsx b/apps/extension/src/components/FeedView.tsx new file mode 100644 index 0000000..af18374 --- /dev/null +++ b/apps/extension/src/components/FeedView.tsx @@ -0,0 +1,144 @@ +/** + * FeedView — "Your Subscriptions" + "Trending This Week" + * + * Feed rules (all enforced in useFeedSections / useTrendingEvents): + * • Only events whose parent email was sent in the last 14 days + * • Sorted newest-first within each org group + * • Subscriptions grouped by org; max 3 rows visible with "Show more" expand + * • Trending: up to 4 individual event cards (single-event per card) + */ + +import { useState } from "react"; +import { ExtensionEventCard } from "@app/ui"; +import type { EventItem } from "../data/types"; +import { useFeedSections, useTrendingEvents } from "../data/useEvents"; + +// ── Typography ───────────────────────────────────────────────────────────── + +// Figma: Inter SemiBold 20px, #5f5f5f, tracking -0.22px, leading 1.5 +const SECTION_HEADING = + "font-[family-name:var(--font-body)] font-semibold " + + "text-[1.25rem] leading-[1.5] tracking-[-0.22px] " + + "text-[#5f5f5f] whitespace-nowrap"; + +// ── Constants ─────────────────────────────────────────────────────────────── + +const MAX_VISIBLE = 3; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +/** Collapses string[] subtitle to a single-line description for ExtensionEventRow. */ +function toRowDescription(event: EventItem): string { + if (Array.isArray(event.subtitle)) return event.subtitle.join(" · "); + return event.subtitle ?? ""; +} + +// ── Props ─────────────────────────────────────────────────────────────────── + +interface FeedViewProps { + bookmarkedIds: Set; + onBookmark: (id: string) => void; + onEmailView: (event: EventItem) => void; +} + +// ── Component ──────────────────────────────────────────────────────────────── + +export default function FeedView({ + bookmarkedIds, + onBookmark, + onEmailView, +}: FeedViewProps) { + const feedSections = useFeedSections(); + const trendingEvents = useTrendingEvents(); + + // Track which org sections have been expanded beyond the 3-event cap. + const [expandedOrgs, setExpandedOrgs] = useState>(new Set()); + + const toggleExpand = (orgName: string) => { + setExpandedOrgs((prev) => { + const next = new Set(prev); + if (next.has(orgName)) { + next.delete(orgName); + } else { + next.add(orgName); + } + return next; + }); + }; + + return ( +
+ {/* ── Your Subscriptions ── */} +
+

Your Subscriptions

+ + {feedSections.length === 0 && ( +

+ No recent emails from your subscriptions. +

+ )} + + {feedSections.map(({ orgName, events }) => { + const isExpanded = expandedOrgs.has(orgName); + const visibleEvents = isExpanded + ? events + : events.slice(0, MAX_VISIBLE); + const hiddenCount = events.length - MAX_VISIBLE; + + return ( + ({ + thumbnailVariant: event.thumbnailVariant, + day: event.day, + month: event.month, + title: event.title, + description: toRowDescription(event), + bookmarked: bookmarkedIds.has(event.id), + onBookmark: () => onBookmark(event.id), + onRowClick: event.isEdgeCase + ? () => onEmailView(event) + : undefined, + }))} + onViewMore={ + !isExpanded && events.length > MAX_VISIBLE + ? () => toggleExpand(orgName) + : undefined + } + onViewLess={isExpanded ? () => toggleExpand(orgName) : undefined} + /> + ); + })} +
+ + {/* ── Trending This Week ── */} + {trendingEvents.length > 0 && ( +
+

Trending This Week

+ + {trendingEvents.map((event) => ( + onBookmark(event.id), + onRowClick: event.isEdgeCase + ? () => onEmailView(event) + : undefined, + }, + ]} + /> + ))} +
+ )} +
+ ); +} diff --git a/apps/extension/src/components/FloatingPanel.tsx b/apps/extension/src/components/FloatingPanel.tsx new file mode 100644 index 0000000..4189ffc --- /dev/null +++ b/apps/extension/src/components/FloatingPanel.tsx @@ -0,0 +1,47 @@ +import { useState } from "react"; +import App from "../App"; +import type { AppProps } from "../App"; +import FloatingIcon from "../../public/floating_icon.svg?react"; + +export interface FloatingPanelProps extends Pick< + AppProps, + "pageContext" | "onPreviewSlot" +> {} + +export default function FloatingPanel({ + pageContext, + onPreviewSlot, +}: FloatingPanelProps) { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> + {/* Floating tab — fades out when panel opens, fades in when closed */} + + + {/* Panel outer shell — slides in/out via inline transform (reliable in shadow DOM) */} +
+ setIsOpen(false)} + pageContext={pageContext} + onPreviewSlot={onPreviewSlot} + /> +
+ + ); +} diff --git a/apps/extension/src/components/OriginalEmailView.tsx b/apps/extension/src/components/OriginalEmailView.tsx new file mode 100644 index 0000000..f3e7037 --- /dev/null +++ b/apps/extension/src/components/OriginalEmailView.tsx @@ -0,0 +1,100 @@ +import { Avatar } from "@app/ui"; +import type { EventItem } from "../data/types"; + +// Figma: Inter SemiBold 20px, #5f5f5f, tracking -0.22px, leading 1.5 +const SECTION_HEADING = + "font-[family-name:var(--font-body)] font-semibold " + + "text-[1.25rem] leading-[1.5] tracking-[-0.22px] " + + "text-[#5f5f5f] whitespace-nowrap"; + +// Figma: DM Sans Bold 18px, #5f5f5f, lh 28px, tracking -0.5px +const EMAIL_TITLE = + "font-[family-name:var(--font-body)] font-bold " + + "text-[length:var(--font-size-body1)] leading-[var(--line-height-body1)] " + + "tracking-[var(--letter-spacing-body1)] text-[#5f5f5f]"; + +// Figma: DM Sans Regular 14px, #5f5f5f, lh 20.2px, tracking -0.5px +const EMAIL_BODY = + "font-[family-name:var(--font-body)] font-normal " + + "text-[length:var(--font-size-body2)] leading-[var(--line-height-body2)] " + + "tracking-[var(--letter-spacing-body2)] text-[#5f5f5f]"; + +// Fallback content shown when no event is selected (shouldn't happen in normal flow) +const FALLBACK_PARAGRAPHS = [ + "Hello Eship,", + "", + "For Cornell builders, aspiring VCs, and startup enthusiasts: Startup Hours is being held from 7:30–9pm Thursday, on the third floor of eHub Collegetown.", + "", + "Please fill out this form for catering by Wednesday 5pm so we have a proper headcount.", + "", + "Best,", + "Alli", +]; + +export interface OriginalEmailViewProps { + /** + * The EventItem whose raw email content should be displayed. + * When null/undefined (shouldn't happen in normal flow) fallback placeholder is shown. + */ + event?: EventItem | null; +} + +export default function OriginalEmailView({ event }: OriginalEmailViewProps) { + const orgName = event?.orgName ?? "Cornell DTI"; + const emailTitle = event?.rawEmailTitle ?? event?.title ?? "Original Email"; + const paragraphs = event?.rawEmailParagraphs ?? FALLBACK_PARAGRAPHS; + + return ( +
+ {/* "Original Email" heading — Inter SemiBold 20px */} +

Original Email

+ + {/* Email card — Figma: white bg, #ececec 1.5px border, rounded-[12px], p-[16px], gap-[20px] */} +
+ {/* Org header — avatar circle + org name */} + + + {/* Email content */} +
+ {/* Subject / title — DM Sans Bold 18px */} +

+ {emailTitle} +

+ + {/* Body paragraphs — empty strings become blank-line spacers */} +
+ {paragraphs.map((line, i) => + line === "" ? ( + +
+
+
+ ); +} diff --git a/apps/extension/src/components/SearchHeader.tsx b/apps/extension/src/components/SearchHeader.tsx new file mode 100644 index 0000000..628b437 --- /dev/null +++ b/apps/extension/src/components/SearchHeader.tsx @@ -0,0 +1,185 @@ +/** + * SearchHeader — Extension panel header + * + * Two variants matching the Figma designs: + * + * 'main' (node 528:4032) — default view header + * Logo + Close | SearchBar | Toggle (Feed / Bookmarks) + * + * 'search' (node 554:7828) — active search / original-email view header + * Logo + Close | [← back] SearchBar (no toggle) + */ + +import type { ComponentPropsWithoutRef } from "react"; +import { SearchBar, Toggle, LoopLogo } from "@app/ui"; +import CloseIcon from "@app/ui/assets/close_search.svg?react"; +import ChevronBackIcon from "@app/ui/assets/chevron-back.svg?react"; + +// ── Public types ─────────────────────────────────────────────────────────── + +export type SearchHeaderVariant = "main" | "search"; + +export interface SearchHeaderProps extends Omit< + ComponentPropsWithoutRef<"div">, + "onChange" +> { + /** + * Layout variant: + * 'main' → SearchBar + Feed/Bookmarks Toggle below logo row + * 'search' → Back chevron + SearchBar below logo row (no toggle) + * Defaults to 'main'. + */ + variant?: SearchHeaderVariant; + + /** Called when the × close button is clicked. */ + onClose?: () => void; + + /** Controlled search query string. */ + searchQuery?: string; + /** Called with the new value whenever the search input changes. */ + onSearchChange?: (value: string) => void; + /** Called when the × clear button inside the search bar is clicked. */ + onSearchClear?: () => void; + /** Called when the search input receives focus (used to switch to search view). */ + onSearchFocus?: () => void; + + /** + * Called when the ‹ back chevron is clicked. + * Only relevant in the 'search' variant. + */ + onBack?: () => void; + + /** + * Currently active toggle tab value ('feed' | 'bookmarks'). + * Only relevant in the 'main' variant. Defaults to 'feed'. + */ + activeTab?: string; + /** + * Called with the new tab value when a toggle option is selected. + * Only relevant in the 'main' variant. + */ + onTabChange?: (tab: string) => void; +} + +// ── Constants ────────────────────────────────────────────────────────────── + +const TOGGLE_OPTIONS = [ + { value: "feed", label: "Feed" }, + { value: "bookmarks", label: "Bookmarks" }, +]; + +// Shared icon-button wrapper — 25.6 px hit target, subtle hover bg +const ICON_BTN = + "shrink-0 flex items-center justify-center " + + "size-[var(--space-6)] rounded-[var(--radius-input)] cursor-pointer " + + "hover:bg-[var(--color-surface-subtle)] transition-colors duration-150 " + + "focus-visible:outline-2 focus-visible:outline-offset-1 " + + "focus-visible:outline-[var(--color-primary-700)]"; + +// ── Component ────────────────────────────────────────────────────────────── + +export function SearchHeader({ + variant = "main", + onClose, + searchQuery = "", + onSearchChange, + onSearchClear, + onSearchFocus, + onBack, + activeTab = "feed", + onTabChange, + className, + ...rest +}: SearchHeaderProps) { + return ( +
+ {/* ── Logo row: wordmark left, close right ── */} +
+ {/* size="sm": 24px mark + 32px "Loop" wordmark — matches Figma panel header */} + + + +
+ + {/* ── Search row ── */} + {variant === "main" ? ( + /* Main variant: search bar spans full width */ + + ) : ( + /* Search variant: back chevron + search bar (flex-1). + Figma gap between chevron and input: 4px (--space-1) */ +
+ + + +
+ )} + + {/* ── Toggle (main variant only) ── */} + {variant === "main" && ( + {})} + size="compact" + className="w-full" + /> + )} +
+ ); +} + +export default SearchHeader; diff --git a/apps/extension/src/components/SearchView.tsx b/apps/extension/src/components/SearchView.tsx new file mode 100644 index 0000000..95cb484 --- /dev/null +++ b/apps/extension/src/components/SearchView.tsx @@ -0,0 +1,229 @@ +/** + * SearchView — empty state (popular searches) + results state. + * + * Empty state (no query): + * • Clicking a popular search row calls onSearchSelect(term) → populates the + * search bar in App.tsx via handleSearchSelect. + * + * Results state (query present): + * • Filters MOCK_EVENTS using useSearchResults(query). + * • "Sort by" tag strip filters results further (OR match). + * • Tag strip supports +, pencil edit mode, and × delete via SortByTags. + * • Each BookmarkCard wires RSVP / Add to Calendar / bookmark actions. + */ + +import { useState } from "react"; +import type { EventItem } from "../data/types"; +import { useSearchResults } from "../data/useEvents"; +import { + getPrimaryLink, + getLinkLabel, + openExternalUrl, +} from "../utils/linkUtils"; +import { buildGCalUrl } from "../utils/calendarUtils"; +import { BookmarkCard } from "./BookmarkCard"; +import { SortByTags } from "./SortByTags"; + +// ── Shared typography ────────────────────────────────────────────────────── + +// Figma: Inter Regular 16px, #5f5f5f, tracking -0.176px, leading 1.5 +const UI_BODY = + "font-[family-name:var(--font-body)] font-normal " + + "text-[1rem] leading-[1.5] tracking-[-0.176px] text-[#5f5f5f]"; + +const SORT_LABEL = UI_BODY + " whitespace-nowrap"; + +// ── Constants ────────────────────────────────────────────────────────────── + +const POPULAR_SEARCHES = [ + { rank: "#1", term: "Recruitment" }, + { rank: "#2", term: "Sports" }, + { rank: "#3", term: "Concert" }, + { rank: "#4", term: "Housing" }, + { rank: "#5", term: "A&S" }, +]; + +const INITIAL_SORT_TAGS = [ + "Internships", + "Early career", + "Tech", + "Mentorship", + "Just for fun", +]; + +// ── SearchEmptyState ─────────────────────────────────────────────────────── + +interface SearchEmptyStateProps { + onSelect: (term: string) => void; +} + +function SearchEmptyState({ onSelect }: SearchEmptyStateProps) { + return ( +
+ {/* Heading — Figma: DM Sans Medium 18px, #5f5f5f, lh 28px, tracking -0.5px */} +

+ Popular searches this week +

+ + {/* Ranked rows */} +
+ {POPULAR_SEARCHES.map(({ rank, term }) => ( + + ))} +
+
+ ); +} + +// ── SearchResultsState ───────────────────────────────────────────────────── + +interface SearchResultsStateProps { + query: string; + bookmarkedIds: Set; + onBookmark: (id: string) => void; + onEmailView: (event: EventItem) => void; +} + +function SearchResultsState({ + query, + bookmarkedIds, + onBookmark, + onEmailView, +}: SearchResultsStateProps) { + const results = useSearchResults(query); + + const [availableTags, setAvailableTags] = useState(INITIAL_SORT_TAGS); + const [activeTags, setActiveTags] = useState([]); + + const handleTagToggle = (tag: string) => + setActiveTags((prev) => + prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag], + ); + + const handleTagAdd = (tag: string) => { + setAvailableTags((prev) => [...prev, tag]); + setActiveTags((prev) => [...prev, tag]); + }; + + const handleTagRemove = (tag: string) => { + setAvailableTags((prev) => prev.filter((t) => t !== tag)); + setActiveTags((prev) => prev.filter((t) => t !== tag)); + }; + + const filtered = + activeTags.length === 0 + ? results + : results.filter((e) => activeTags.some((tag) => e.tags.includes(tag))); + + return ( +
+ {/* Sort by */} +
+

Sort by

+ +
+ + {/* Result cards */} +
+ {filtered.length === 0 && ( +

+ No results found. +

+ )} + + {filtered.map((event) => { + const primaryLink = getPrimaryLink(event); + const primaryAction = primaryLink + ? { + label: getLinkLabel(primaryLink), + onClick: () => openExternalUrl(primaryLink.url), + } + : undefined; + + const onAddToCalendar = event.calendarEvent + ? () => openExternalUrl(buildGCalUrl(event.calendarEvent!)) + : undefined; + + return ( + onEmailView(event) : undefined + } + tags={event.tags} + primaryAction={primaryAction} + onAddToCalendar={onAddToCalendar} + onUnbookmark={ + bookmarkedIds.has(event.id) + ? () => onBookmark(event.id) + : undefined + } + /> + ); + })} +
+
+ ); +} + +// ── SearchView ───────────────────────────────────────────────────────────── + +export interface SearchViewProps { + query?: string; + onSearchSelect?: (term: string) => void; + bookmarkedIds?: Set; + onBookmark?: (id: string) => void; + onEmailView?: (event: EventItem) => void; +} + +export default function SearchView({ + query = "", + onSearchSelect, + bookmarkedIds = new Set(), + onBookmark = () => {}, + onEmailView = () => {}, +}: SearchViewProps) { + return query.trim() === "" ? ( + {})} /> + ) : ( + + ); +} diff --git a/apps/extension/src/components/SortByTags.tsx b/apps/extension/src/components/SortByTags.tsx new file mode 100644 index 0000000..7f8f1a4 --- /dev/null +++ b/apps/extension/src/components/SortByTags.tsx @@ -0,0 +1,123 @@ +/** + * SortByTags — reusable sort/filter tag strip. + * + * Used in BookmarkView and SearchView. Manages: + * • Active tag toggling (click to filter; active tags use primary/orange bg) + * • Custom tag addition via a "+" inline input (press Enter or blur to add) + * • Edit mode via pencil icon: Tag's built-in onDismiss renders the design- + * system × button; click × to delete the tag + */ + +import { useRef, useState } from "react"; +import { Tag } from "@app/ui"; +import EditIcon from "@app/ui/assets/edit_icon.svg?react"; + +export interface SortByTagsProps { + tags: string[]; + activeTags: string[]; + onTagToggle: (tag: string) => void; + onTagAdd: (tag: string) => void; + onTagRemove: (tag: string) => void; +} + +export function SortByTags({ + tags, + activeTags, + onTagToggle, + onTagAdd, + onTagRemove, +}: SortByTagsProps) { + const [isEditMode, setIsEditMode] = useState(false); + const [showInput, setShowInput] = useState(false); + const [inputValue, setInputValue] = useState(""); + const inputRef = useRef(null); + + const commitNewTag = () => { + const trimmed = inputValue.trim(); + if (trimmed && !tags.includes(trimmed)) { + onTagAdd(trimmed); + } + setInputValue(""); + setShowInput(false); + }; + + const handlePlusClick = () => { + setShowInput(true); + setTimeout(() => inputRef.current?.focus(), 0); + }; + + return ( +
+ {tags.map((label) => { + const isActive = activeTags.includes(label); + + return ( + onTagToggle(label) : undefined} + // In edit mode: show the design-system × dismiss button + onDismiss={isEditMode ? () => onTagRemove(label) : undefined} + dismissLabel={`Remove ${label}`} + aria-pressed={!isEditMode ? isActive : undefined} + > + {label} + + ); + })} + + {/* "+" add new tag — hidden while editing */} + {!isEditMode && !showInput && ( + + + + + )} + + {/* Inline custom tag input */} + {showInput && ( + setInputValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") commitNewTag(); + if (e.key === "Escape") { + setInputValue(""); + setShowInput(false); + } + }} + onBlur={commitNewTag} + placeholder="New tag…" + className={[ + "h-[var(--space-6)] w-[90px] rounded-[var(--radius-button)]", + "border border-[var(--color-primary-700)] bg-white", + "px-[var(--space-2)] text-[length:var(--font-size-body3)]", + "font-[family-name:var(--font-body)] font-normal", + "text-[var(--color-neutral-900)] outline-none", + ].join(" ")} + /> + )} + + {/* Pencil / edit-mode toggle */} + +
+ ); +} diff --git a/apps/extension/src/content.css b/apps/extension/src/content.css new file mode 100644 index 0000000..de5650b --- /dev/null +++ b/apps/extension/src/content.css @@ -0,0 +1,19 @@ +@import "tailwindcss"; + +/* Explicitly scan component files so Tailwind generates all used utility classes */ +@source "../src/**/*.{ts,tsx}"; +@source "../../../shared/ui/src/**/*.{ts,tsx}"; + +@import "../../../shared/ui/src/styles/tokens.css"; + +/* + * Tailwind v4 `.border` uses `border-style: var(--tw-border-style)`. + * `@property --tw-border-style` does not apply inside ShadowRoot adoptedStyleSheets, + * so `--tw-border-style` is invalid and borders disappear (see tailwindcss#16025). + * Force solid borders after Tailwind utilities so width + color utilities still work. + */ +@layer utilities { + .border { + border-style: solid; + } +} diff --git a/apps/extension/src/content.tsx b/apps/extension/src/content.tsx new file mode 100644 index 0000000..15b4474 --- /dev/null +++ b/apps/extension/src/content.tsx @@ -0,0 +1,92 @@ +import { ConvexAuthProvider } from "@convex-dev/auth/react"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { ConvexReactClient } from "convex/react"; +import FloatingPanel from "./components/FloatingPanel.tsx"; +import contentStyles from "./content.css?inline"; +import { showSlotPreview, removeSlotPreview } from "./gcalHighlight"; +import type { EventItem } from "./data/types"; +import type { PageContext } from "./App"; + +const convexUrl = import.meta.env.VITE_CONVEX_URL as string | undefined; + +function loadFonts() { + if (document.getElementById("cornell-loop-fonts")) return; + const link = document.createElement("link"); + link.id = "cornell-loop-fonts"; + link.rel = "stylesheet"; + link.href = + "https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=Inter:wght@400;500;600;700&family=Manrope:wght@600;700&display=swap"; + document.head.appendChild(link); +} + +function mount() { + if (!convexUrl) { + console.warn( + "[Cornell Loop] VITE_CONVEX_URL is not set. " + + "Create apps/extension/.env.local with VITE_CONVEX_URL= and rebuild.", + ); + return; + } + + if (document.getElementById("cornell-loop-host")) return; + + const convex = new ConvexReactClient(convexUrl); + + // Detect which Google product the content script is running in. + const pageContext: PageContext = window.location.hostname.includes( + "calendar.google.com", + ) + ? "gcal" + : "gmail"; + + loadFonts(); + + const host = document.createElement("div"); + host.id = "cornell-loop-host"; + // Explicit styles prevent Gmail/Calendar from accidentally hiding or + // reflowing the host. Fixed + zero-size keeps it out of the page layout + // while still allowing the shadow DOM children to use fixed positioning. + host.style.cssText = + "position:fixed;top:0;left:0;width:0;height:0;z-index:2147483647;overflow:visible;pointer-events:none;"; + document.body.appendChild(host); + + const shadow = host.attachShadow({ mode: "open" }); + + // Use a