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,