diff --git a/functions/index.js b/functions/index.js index 1ae9b04..1d393d9 100644 --- a/functions/index.js +++ b/functions/index.js @@ -28,3 +28,4 @@ exports.thumbnail = require("./src/thumbnail"); exports.upload = require("./src/upload"); exports.algolia = require("./src/algolia"); exports.notifications = require("./src/notifications"); +exports.rankings = require("./src/rankings"); diff --git a/functions/src/rankings.js b/functions/src/rankings.js new file mode 100644 index 0000000..3005634 --- /dev/null +++ b/functions/src/rankings.js @@ -0,0 +1,91 @@ +const admin = require("firebase-admin"); +const { onSchedule } = require("firebase-functions/v2/scheduler"); +const { logger } = require("firebase-functions/logger"); + +const db = admin.firestore(); + +function buildMonthContext(referenceDate) { + const current = referenceDate ? new Date(referenceDate) : new Date(); + current.setHours(0, 0, 0, 0); + current.setDate(1); + + const target = new Date(current); + target.setMonth(target.getMonth() - 1); + + const month = target.getMonth(); + const year = target.getFullYear(); + const monthKey = `${year}-${String(month + 1).padStart(2, "0")}`; + const rangeStart = new Date(year, month, 1, 0, 0, 0, 0); + const rangeEnd = new Date(year, month + 1, 0, 23, 59, 59, 999); + + return { + year, + month: month + 1, + monthKey, + rangeStart, + rangeEnd, + }; +} + +exports.snapshotMonthlyTopSongs = onSchedule( + { + schedule: "5 0 1 * *", + timeZone: "Europe/Paris", + }, + async (event) => { + const { scheduleTime } = event; + const context = buildMonthContext( + scheduleTime ? new Date(scheduleTime) : new Date(), + ); + + logger.info( + `[snapshotMonthlyTopSongs] Start ranking for ${context.monthKey}`, + ); + + const topProjectsSnap = await db + .collection("projects") + .orderBy("views", "desc") + .limit(3) + .get(); + + const topProjects = topProjectsSnap.docs.map((doc, index) => { + const data = doc.data() || {}; + return { + rank: index + 1, + projectId: doc.id, + title: data.title || null, + userId: data.userId || null, + userName: data.userName || null, + coverUrl: data.coverUrl || null, + songUrl: data.songUrl || null, + views: data.views || 0, + }; + }); + + const docRef = db.collection("monthlyTopSongs").doc(context.monthKey); + + const existingSnapshot = await docRef.get(); + const payload = { + monthKey: context.monthKey, + month: context.month, + year: context.year, + range: { + start: admin.firestore.Timestamp.fromDate(context.rangeStart), + end: admin.firestore.Timestamp.fromDate(context.rangeEnd), + }, + topProjects, + totalProjects: topProjects.length, + updatedAt: admin.firestore.FieldValue.serverTimestamp(), + }; + + if (!existingSnapshot.exists) { + payload.createdAt = admin.firestore.FieldValue.serverTimestamp(); + } + + await docRef.set(payload, { merge: true }); + + logger.info( + `[snapshotMonthlyTopSongs] Stored ${topProjects.length} projects for ${context.monthKey}`, + ); + }, +); diff --git a/src/components/ProjectDropDown/ProjectDropDown.js b/src/components/ProjectDropDown/ProjectDropDown.js index bfd7929..7cd2068 100644 --- a/src/components/ProjectDropDown/ProjectDropDown.js +++ b/src/components/ProjectDropDown/ProjectDropDown.js @@ -1,4 +1,5 @@ import { BlurView } from "expo-blur"; +import { Portal } from "@gorhom/portal"; import React, { useCallback, useEffect, @@ -52,8 +53,27 @@ const ProjectDropDown = ({ }) => { const [isOpen, setIsOpen] = useState(false); const [triggerLayout, setTriggerLayout] = useState(null); + const [triggerWindowLayout, setTriggerWindowLayout] = useState(null); + const triggerRef = useRef(null); const dropdownAnimation = useRef(new Animated.Value(0)).current; + const measureTriggerPosition = useCallback(() => { + if (triggerRef.current?.measureInWindow) { + try { + triggerRef.current.measureInWindow((x, y, width, height) => { + setTriggerWindowLayout({ + x: x ?? 0, + y: y ?? 0, + width: width ?? triggerLayout?.width ?? 0, + height: height ?? triggerLayout?.height ?? 0, + }); + }); + } catch (error) { + console.warn("ProjectDropDown: measureInWindow failed", error); + } + } + }, [triggerLayout?.height, triggerLayout?.width]); + const normalizedProjects = Array.isArray(projects) ? projects : []; const isDisabled = normalizedProjects.length === 0; @@ -80,8 +100,11 @@ const ProjectDropDown = ({ if (isDisabled) { return; } + if (!isOpen) { + measureTriggerPosition(); + } setIsOpen((prev) => !prev); - }, [isDisabled]); + }, [isDisabled, isOpen, measureTriggerPosition]); const closeDropdown = useCallback(() => setIsOpen(false), []); @@ -121,6 +144,12 @@ const ProjectDropDown = ({ }).start(); }, [dropdownAnimation, isOpen]); + useEffect(() => { + if (isOpen) { + measureTriggerPosition(); + } + }, [isOpen, measureTriggerPosition]); + const overlayTranslateY = useMemo( () => dropdownAnimation.interpolate({ @@ -158,15 +187,81 @@ const ProjectDropDown = ({ [dropdownAnimation] ); + const overlayPositionStyle = useMemo(() => { + const widthCandidate = + dropdownWidth || + triggerWindowLayout?.width || + triggerLayout?.width || + 0; + + const width = widthCandidate || 0; + const windowWidth = Dimensions.get("window").width || 0; + const leftBase = triggerWindowLayout?.x ?? 0; + const left = + width > 0 && windowWidth > 0 + ? Math.min(Math.max(leftBase, 0), Math.max(0, windowWidth - width)) + : Math.max(leftBase, 0); + + return { + top: triggerWindowLayout?.y ?? 0, + left, + width: width || undefined, + }; + }, [dropdownWidth, triggerLayout?.width, triggerWindowLayout]); + + const dropdownContent = ( + + + + + + project?.id || project?.title || `project-${index}` + } + ItemSeparatorComponent={() => } + renderItem={({ item: project }) => ( + + )} + nestedScrollEnabled + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.dropdownListContent} + style={styles.dropdownList} + /> + + + + ); + return ( - {isOpen ? ( - - ) : null} - setTriggerLayout(event.nativeEvent.layout)} + onLayout={(event) => { + setTriggerLayout(event.nativeEvent.layout); + measureTriggerPosition(); + }} > - - - - - - - project?.id || project?.title || `project-${index}` - } - ItemSeparatorComponent={() => } - renderItem={({ item: project }) => ( - - )} - showsVerticalScrollIndicator={false} - contentContainerStyle={styles.dropdownListContent} - style={styles.dropdownList} - /> - + {isOpen && triggerWindowLayout ? ( + + + + + {dropdownContent} + - - + + ) : null} ); @@ -435,6 +498,11 @@ const styles = StyleSheet.create({ chevronOpen: { transform: [{ rotate: "180deg" }], }, + portalContainer: { + ...StyleSheet.absoluteFillObject, + zIndex: 999, + elevation: 999, + }, backdrop: { ...StyleSheet.absoluteFillObject, zIndex: 1,