fix list and improve cloud functions
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
const admin = require("firebase-admin");
|
||||
|
||||
async function deleteFolder(path) {
|
||||
try {
|
||||
console.log(`Deleting folder: ${path}`);
|
||||
const bucket = admin.storage().bucket();
|
||||
await bucket.deleteFiles({ prefix: path, force: true });
|
||||
console.log(`Folder ${path} deleted successfully`);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
|
||||
exports.deleteFolder = deleteFolder;
|
||||
@@ -164,7 +164,7 @@ ${policy}
|
||||
return output;
|
||||
};
|
||||
|
||||
exports.generateImageV2 = async (prompt, size = 1024) => {
|
||||
exports.generateImageV2 = async (prompt, size = 1024, path = "") => {
|
||||
if (typeof prompt !== "string" || prompt.trim().length < 1) {
|
||||
throw new Error("Vous devez spécifier un prompt (string) non vide.");
|
||||
}
|
||||
@@ -280,14 +280,7 @@ exports.generateImageV2 = async (prompt, size = 1024) => {
|
||||
|
||||
const buffer = Buffer.from(b64, "base64");
|
||||
const bucket = admin.storage().bucket();
|
||||
const ext =
|
||||
mimeType === "image/jpeg"
|
||||
? "jpg"
|
||||
: mimeType === "image/webp"
|
||||
? "webp"
|
||||
: "png";
|
||||
const token = require("crypto").randomUUID();
|
||||
const path = `generated/images/${Date.now()}-${token}.${ext}`;
|
||||
|
||||
console.log(
|
||||
"💾 [generateImageV2] Sauvegarde de l'image dans Firebase Storage",
|
||||
@@ -327,6 +320,7 @@ exports.CombineCoverAndPicture = async (
|
||||
backgroundImage,
|
||||
foregroundImage,
|
||||
options = {},
|
||||
path = "",
|
||||
) => {
|
||||
const size = Number(options.size || 1024);
|
||||
console.log("🧩 [CombineCoverAndPicture] Start", { size });
|
||||
@@ -441,14 +435,7 @@ Tu reçois 2 images: la première est l'arrière-plan, la seconde est une photo
|
||||
|
||||
const buffer = Buffer.from(b64, "base64");
|
||||
const bucket = admin.storage().bucket();
|
||||
const ext =
|
||||
mimeType === "image/jpeg"
|
||||
? "jpg"
|
||||
: mimeType === "image/webp"
|
||||
? "webp"
|
||||
: "png";
|
||||
const token = require("crypto").randomUUID();
|
||||
const path = `generated/combined/${Date.now()}-${token}.${ext}`;
|
||||
|
||||
await bucket.file(path).save(buffer, {
|
||||
resumable: false,
|
||||
|
||||
+3
-1
@@ -6,12 +6,14 @@ admin.initializeApp();
|
||||
|
||||
exports.refList = {
|
||||
projects: admin.firestore().collection("projects"),
|
||||
playlists: admin.firestore().collection("playlists"),
|
||||
tasks: admin.firestore().collection("tasks"),
|
||||
};
|
||||
|
||||
// Exporter toutes les fonctions
|
||||
exports.users = require("./src/users");
|
||||
exports.music = require("./src/music");
|
||||
exports.lyrics = require("./src/lyrics");
|
||||
exports.cover = require("./src/cover");
|
||||
exports.projects = require("./src/project");
|
||||
exports.hls = require("./src/hls");
|
||||
exports.thumbnail = require("./src/thumbnail");
|
||||
|
||||
+10
-2
@@ -21,7 +21,11 @@ async function performCoverGeneration(project) {
|
||||
let coverUrl = "";
|
||||
try {
|
||||
// Utiliser une taille fixe de 1024 pixels
|
||||
coverUrl = await generateImageV2(prompt, 1024);
|
||||
coverUrl = await generateImageV2(
|
||||
prompt,
|
||||
1024,
|
||||
`users/${project.userId}/projects/${project.id}/generated-${Date.now()}.png`,
|
||||
);
|
||||
console.log("coverUrl", coverUrl);
|
||||
} catch (e) {
|
||||
logger.error("❌ [Cover] generateImageV2 failed", {
|
||||
@@ -73,7 +77,11 @@ async function performCombineGeneration(project) {
|
||||
fgPreview: String(foreground).slice(0, 80),
|
||||
});
|
||||
|
||||
const combinedUrl = await CombineCoverAndPicture(background, foreground);
|
||||
const combinedUrl = await CombineCoverAndPicture(
|
||||
background,
|
||||
foreground,
|
||||
`users/${project.userId}/projects/${project.id}/combine-${Date.now()}.png`,
|
||||
);
|
||||
|
||||
// Sauvegarder le résultat et marquer comme généré
|
||||
await db
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
const { onObjectFinalized } = require("firebase-functions/v2/storage");
|
||||
const functions = require("firebase-functions");
|
||||
const admin = require("firebase-admin");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const fs = require("fs");
|
||||
|
||||
// These deps must be installed in functions/: ffmpeg-static, fluent-ffmpeg
|
||||
let ffmpeg;
|
||||
let ffmpegPath;
|
||||
try {
|
||||
ffmpeg = require("fluent-ffmpeg");
|
||||
ffmpegPath = require("ffmpeg-static");
|
||||
ffmpeg.setFfmpegPath(ffmpegPath);
|
||||
} catch (e) {
|
||||
console.log(
|
||||
"ffmpeg modules not installed yet — deploy after npm i",
|
||||
e?.message
|
||||
);
|
||||
}
|
||||
|
||||
const bucket = admin.storage().bucket();
|
||||
|
||||
exports.transcodePlaybackToHLS = onObjectFinalized(
|
||||
{
|
||||
region: "europe-west1",
|
||||
memory: "2GiB",
|
||||
timeoutSeconds: 540,
|
||||
cpu: 2,
|
||||
},
|
||||
async (event) => {
|
||||
const object = event.data;
|
||||
const filePath = object?.name || ""; // e.g., musics/{projectId}/source.mp4
|
||||
const contentType = object?.contentType || "";
|
||||
|
||||
try {
|
||||
// Only process our target path + video files
|
||||
const match = filePath.match(/^musics\/(.+?)\/source\.mp4$/i);
|
||||
if (!match || !contentType.startsWith("video/")) {
|
||||
return;
|
||||
}
|
||||
const projectId = match[1];
|
||||
const tempDir = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), `hls_${projectId}_`)
|
||||
);
|
||||
const localSource = path.join(tempDir, "source.mp4");
|
||||
const hlsDir = path.join(tempDir, "hls");
|
||||
fs.mkdirSync(hlsDir);
|
||||
|
||||
// Download source
|
||||
await bucket.file(filePath).download({ destination: localSource });
|
||||
|
||||
if (!ffmpeg) {
|
||||
console.warn("ffmpeg not available; skip transcode");
|
||||
return;
|
||||
}
|
||||
|
||||
// Transcode to HLS (single rendition for simplicity)
|
||||
const masterPath = path.join(hlsDir, "master.m3u8");
|
||||
const segmentPattern = path.join(hlsDir, "segment_%03d.ts");
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
ffmpeg(localSource)
|
||||
.outputOptions([
|
||||
"-profile:v baseline",
|
||||
"-level 3.0",
|
||||
"-start_number 0",
|
||||
"-hls_time 4",
|
||||
"-hls_list_size 0",
|
||||
"-f hls",
|
||||
`-hls_segment_filename ${segmentPattern}`,
|
||||
])
|
||||
.output(masterPath)
|
||||
.on("end", resolve)
|
||||
.on("error", reject)
|
||||
.run();
|
||||
});
|
||||
|
||||
// Upload all files in hlsDir to Storage under musics/{projectId}/hls/
|
||||
const fileNames = fs.readdirSync(hlsDir);
|
||||
const destPrefix = `musics/${projectId}/hls/`;
|
||||
|
||||
// Upload all segments first
|
||||
const uploads = fileNames
|
||||
.filter((n) => n.endsWith(".ts"))
|
||||
.map((name) => {
|
||||
const local = path.join(hlsDir, name);
|
||||
const dest = `${destPrefix}${name}`;
|
||||
return bucket.upload(local, {
|
||||
destination: dest,
|
||||
metadata: {
|
||||
contentType: "video/MP2T",
|
||||
cacheControl: "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
});
|
||||
await Promise.all(uploads);
|
||||
|
||||
// Generate signed URLs for segments and rewrite playlist with absolute URLs
|
||||
const segmentNames = fileNames.filter((n) => n.endsWith(".ts"));
|
||||
const signedSegments = {};
|
||||
await Promise.all(
|
||||
segmentNames.map(async (name) => {
|
||||
const file = bucket.file(`${destPrefix}${name}`);
|
||||
const [url] = await file.getSignedUrl({
|
||||
action: "read",
|
||||
expires: Date.now() + 1000 * 60 * 60 * 24 * 365,
|
||||
});
|
||||
signedSegments[name] = url;
|
||||
})
|
||||
);
|
||||
|
||||
// Rewrite master playlist to use absolute signed segment URLs
|
||||
let masterContent = fs.readFileSync(masterPath, "utf8");
|
||||
masterContent = masterContent
|
||||
.split(/\r?\n/)
|
||||
.map((line) =>
|
||||
line.endsWith(".ts") && signedSegments[line]
|
||||
? signedSegments[line]
|
||||
: line
|
||||
)
|
||||
.join("\n");
|
||||
const rewrittenMasterLocal = path.join(hlsDir, "master.abs.m3u8");
|
||||
fs.writeFileSync(rewrittenMasterLocal, masterContent, "utf8");
|
||||
|
||||
// Upload rewritten playlist
|
||||
await bucket.upload(rewrittenMasterLocal, {
|
||||
destination: `${destPrefix}master.m3u8`,
|
||||
metadata: {
|
||||
contentType: "application/vnd.apple.mpegurl",
|
||||
cacheControl: "public, max-age=3600",
|
||||
},
|
||||
});
|
||||
|
||||
// Compute signed URL for playlist
|
||||
const [signedUrl] = await bucket
|
||||
.file(`${destPrefix}master.m3u8`)
|
||||
.getSignedUrl({
|
||||
action: "read",
|
||||
expires: Date.now() + 1000 * 60 * 60 * 24 * 365,
|
||||
});
|
||||
|
||||
// Update Firestore project doc with playbackUrl
|
||||
await admin.firestore().collection("projects").doc(projectId).set(
|
||||
{
|
||||
playbackUrl: signedUrl,
|
||||
playbackStatus: "ready",
|
||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
);
|
||||
|
||||
// Cleanup tmp dir
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch (e) {}
|
||||
} catch (e) {
|
||||
console.error("HLS transcode error", e);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -490,18 +490,16 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
|
||||
|
||||
// 1) Récupérer le projectId associé au taskId
|
||||
let projectId = null;
|
||||
try {
|
||||
const projSnap = await db
|
||||
.collection("projects")
|
||||
.where("sunoTaskId", "==", taskId)
|
||||
.limit(1)
|
||||
.get();
|
||||
if (!projSnap.empty) {
|
||||
if (projSnap.empty) {
|
||||
throw new Error("Aucun projet trouvé pour ce taskId");
|
||||
}
|
||||
projectId = projSnap.docs[0].id;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("❌ [SunoCallback] Erreur lookup project par taskId:", e);
|
||||
}
|
||||
const { userId } = projSnap.docs[0].data() || {};
|
||||
|
||||
if (!projectId) {
|
||||
console.warn("⚠️ [SunoCallback] Aucun projet trouvé pour", { taskId });
|
||||
@@ -540,7 +538,7 @@ exports.sunoCallback = onRequest({ methods: ["POST"] }, async (req, res) => {
|
||||
console.log(`⬇️ [SunoCallback] Téléchargement piste ${index + 1}`);
|
||||
const resp = await axios.get(url, { responseType: "arraybuffer" });
|
||||
const buffer = Buffer.from(resp.data);
|
||||
const path = `musics/${projectId}/sound${index + 1}.mp3`;
|
||||
const path = `users/${userId}/projects/${projectId}/task-${taskId}-${index + 1}.mp3`;
|
||||
const token = require("crypto").randomUUID();
|
||||
const file = bucket.file(path);
|
||||
await file.save(buffer, {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
const admin = require("firebase-admin");
|
||||
const { onDocumentWritten } = require("firebase-functions/firestore");
|
||||
const { refList } = require("../index");
|
||||
const { getSunoTimestamps } = require("./lyrics");
|
||||
const { deleteFolder } = require("../helpers/firebase");
|
||||
|
||||
exports.onProjectUpdate = onDocumentWritten(
|
||||
"projects/{projectId}",
|
||||
@@ -13,6 +16,24 @@ exports.onProjectUpdate = onDocumentWritten(
|
||||
if (!previousData?.songUrl && currentData?.songUrl) {
|
||||
await getSunoTimestamps(projectId);
|
||||
}
|
||||
|
||||
if (!currentData) {
|
||||
const { userId } = previousData || {};
|
||||
|
||||
//delete folder
|
||||
await deleteFolder(`users/${userId}/projects/${projectId}/`);
|
||||
|
||||
//delete tasks
|
||||
const snapshot = await refList.tasks
|
||||
.where("projectId", "==", projectId)
|
||||
.get();
|
||||
if (snapshot.empty) return null;
|
||||
const batch = admin.firestore().batch();
|
||||
snapshot.forEach((doc) => {
|
||||
batch.delete(doc.ref);
|
||||
});
|
||||
await batch.commit();
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ exports.generateVideoThumbnail = onObjectFinalized(
|
||||
// Use unique folder under /tmp to avoid name collisions
|
||||
const tmpDir = path.join(
|
||||
os.tmpdir(),
|
||||
`thumb-${Date.now()}-${Math.round(Math.random() * 1000)}`
|
||||
`thumb-${Date.now()}-${Math.round(Math.random() * 1000)}`,
|
||||
);
|
||||
const baseName = path.basename(objectName);
|
||||
const dirName = path.posix.dirname(objectName);
|
||||
@@ -96,7 +96,7 @@ exports.generateVideoThumbnail = onObjectFinalized(
|
||||
thumbnailUrl,
|
||||
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
|
||||
},
|
||||
{ merge: true }
|
||||
{ merge: true },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,5 +115,5 @@ exports.generateVideoThumbnail = onObjectFinalized(
|
||||
} finally {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const admin = require("firebase-admin");
|
||||
const { onDocumentDeleted } = require("firebase-functions/firestore");
|
||||
const { refList } = require("../index");
|
||||
const { deleteFolder } = require("../helpers/firebase");
|
||||
|
||||
exports.onUserDelete = onDocumentDeleted("users/{userID}", async (event) => {
|
||||
try {
|
||||
const userID = event?.params?.userID;
|
||||
await clearAllUserData(userID);
|
||||
|
||||
await deleteFolder(`users/${userID}/`);
|
||||
|
||||
await admin.auth().deleteUser(userID);
|
||||
console.log(`User ${userID} deleted successfully`);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
});
|
||||
|
||||
async function clearAllUserData(userID) {
|
||||
const deleteAll = async (ref, key, operator = "==") => {
|
||||
const snapshot = await ref.where(key, operator, userID).get();
|
||||
snapshot.forEach((item) => item.ref.delete());
|
||||
};
|
||||
const removeFromArray = async (ref, arrayName) => {
|
||||
const snapshot = await ref.where(arrayName, "array-contains", userID).get();
|
||||
snapshot.forEach((item) =>
|
||||
item.ref.update({
|
||||
[arrayName]: admin.firestore.FieldValue.arrayRemove(userID),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
await deleteAll(refList.projects, "userId");
|
||||
await deleteAll(refList.playlists, "createdBy");
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.1 MiB |
@@ -223,6 +223,7 @@ export const background = {
|
||||
hitParadeBG,
|
||||
homeBG,
|
||||
homeBGWeb: require("./UI/homeBGWeb.png"),
|
||||
loginBgWeb: require("./UI/loginBgWeb.png"),
|
||||
};
|
||||
|
||||
export const ai = {
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { FlatList, StyleSheet, View, useWindowDimensions } from "react-native";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
FlatList,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
View,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import PersonaCard from "../cards/PersonaCard/PersonaCard";
|
||||
import { ai } from "../../assets";
|
||||
import { getCreationStageStates } from "../../utils/projectStages";
|
||||
@@ -53,29 +65,207 @@ const FeatureCarousel = ({
|
||||
})),
|
||||
[stageStates],
|
||||
);
|
||||
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const itemHeight = Math.max(windowHeight, 1);
|
||||
const isWeb = Platform.OS === "web";
|
||||
|
||||
const [viewportHeight, setViewportHeight] = useState(() =>
|
||||
Math.max(windowHeight, 1),
|
||||
);
|
||||
|
||||
const updateSnapHeight = useCallback((height) => {
|
||||
if (!height || Number.isNaN(height)) {
|
||||
return;
|
||||
}
|
||||
setViewportHeight((prev) => {
|
||||
if (prev == null || Math.abs(prev - height) > 0.5) {
|
||||
return height;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateSnapHeight(Math.max(windowHeight, 1));
|
||||
}, [updateSnapHeight, windowHeight]);
|
||||
|
||||
const itemHeight = Math.max(viewportHeight, 1);
|
||||
|
||||
const listRef = useRef(null);
|
||||
const pendingScrollRef = useRef(false);
|
||||
const activeIndexRef = useRef(activeIndex);
|
||||
const alignTimeoutRef = useRef(null);
|
||||
const activeIndexRef = useRef(
|
||||
typeof activeIndex === "number" ? activeIndex : 0,
|
||||
);
|
||||
const onActiveIndexChangeRef = useRef(onActiveIndexChange);
|
||||
|
||||
useEffect(() => {
|
||||
activeIndexRef.current = activeIndex;
|
||||
}, [activeIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
onActiveIndexChangeRef.current = onActiveIndexChange;
|
||||
}, [onActiveIndexChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof activeIndex === "number") {
|
||||
activeIndexRef.current = activeIndex;
|
||||
}
|
||||
}, [activeIndex]);
|
||||
|
||||
const clampIndex = useCallback(
|
||||
(index) => {
|
||||
if (!carouselItems.length) {
|
||||
return 0;
|
||||
}
|
||||
if (index < 0) {
|
||||
return 0;
|
||||
}
|
||||
if (index >= carouselItems.length) {
|
||||
return carouselItems.length - 1;
|
||||
}
|
||||
return index;
|
||||
},
|
||||
[carouselItems.length],
|
||||
);
|
||||
|
||||
const clearPendingAlignment = useCallback(() => {
|
||||
if (alignTimeoutRef.current != null) {
|
||||
clearTimeout(alignTimeoutRef.current);
|
||||
alignTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => clearPendingAlignment(), [clearPendingAlignment]);
|
||||
|
||||
const scrollToIndex = useCallback(
|
||||
(index, animated = true, heightOverride) => {
|
||||
const ref = listRef.current;
|
||||
if (!ref) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clamped = clampIndex(index);
|
||||
const height =
|
||||
heightOverride && heightOverride > 0 ? heightOverride : itemHeight;
|
||||
|
||||
if (isWeb) {
|
||||
if (!height) {
|
||||
return;
|
||||
}
|
||||
updateSnapHeight(height);
|
||||
try {
|
||||
ref.scrollToOffset({ offset: clamped * height, animated: false });
|
||||
} catch (_error) {
|
||||
// Ignore scroll errors when list is not ready yet.
|
||||
}
|
||||
activeIndexRef.current = clamped;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
pendingScrollRef.current = !!animated;
|
||||
ref.scrollToIndex({ index: clamped, animated });
|
||||
activeIndexRef.current = clamped;
|
||||
} catch (_error) {
|
||||
pendingScrollRef.current = false;
|
||||
}
|
||||
},
|
||||
[clampIndex, isWeb, itemHeight, updateSnapHeight],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
listRef.current == null ||
|
||||
typeof activeIndex !== "number" ||
|
||||
activeIndex < 0 ||
|
||||
activeIndex >= carouselItems.length ||
|
||||
(isWeb && !itemHeight)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
scrollToIndex(activeIndex);
|
||||
}, [activeIndex, carouselItems.length, isWeb, itemHeight, scrollToIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (listRef.current == null || (isWeb && !itemHeight)) {
|
||||
return;
|
||||
}
|
||||
scrollToIndex(activeIndexRef.current, false);
|
||||
}, [carouselItems.length, isWeb, itemHeight, scrollToIndex]);
|
||||
|
||||
const alignToOffset = useCallback(
|
||||
(offset, layoutHeight) => {
|
||||
const height =
|
||||
layoutHeight && layoutHeight > 0 ? layoutHeight : itemHeight;
|
||||
if (!height) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSnapHeight(height);
|
||||
|
||||
const nextIndex = clampIndex(Math.round(offset / height));
|
||||
const hasChanged = nextIndex !== activeIndexRef.current;
|
||||
|
||||
if (hasChanged) {
|
||||
activeIndexRef.current = nextIndex;
|
||||
const callback = onActiveIndexChangeRef.current;
|
||||
if (callback) {
|
||||
callback(nextIndex);
|
||||
}
|
||||
}
|
||||
|
||||
if (isWeb || hasChanged) {
|
||||
scrollToIndex(nextIndex, !isWeb, height);
|
||||
}
|
||||
},
|
||||
[clampIndex, isWeb, itemHeight, scrollToIndex, updateSnapHeight],
|
||||
);
|
||||
|
||||
const handleScrollEnd = useCallback(
|
||||
(event) => {
|
||||
clearPendingAlignment();
|
||||
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0;
|
||||
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height;
|
||||
alignToOffset(offsetY, layoutHeight);
|
||||
},
|
||||
[alignToOffset, clearPendingAlignment],
|
||||
);
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(event) => {
|
||||
if (!isWeb) {
|
||||
return;
|
||||
}
|
||||
const offsetY = event?.nativeEvent?.contentOffset?.y ?? 0;
|
||||
const layoutHeight = event?.nativeEvent?.layoutMeasurement?.height;
|
||||
clearPendingAlignment();
|
||||
alignTimeoutRef.current = setTimeout(() => {
|
||||
alignToOffset(offsetY, layoutHeight);
|
||||
alignTimeoutRef.current = null;
|
||||
}, 80);
|
||||
},
|
||||
[alignToOffset, clearPendingAlignment, isWeb],
|
||||
);
|
||||
|
||||
const handleLayout = useCallback(
|
||||
(event) => {
|
||||
const layoutHeight = event?.nativeEvent?.layout?.height ?? 0;
|
||||
if (layoutHeight > 0) {
|
||||
updateSnapHeight(layoutHeight);
|
||||
}
|
||||
},
|
||||
[updateSnapHeight],
|
||||
);
|
||||
|
||||
const keyExtractor = useCallback((item) => item.key, []);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item, index }) => (
|
||||
<PersonaCard item={item} index={index} isLock={item.isLocked} />
|
||||
<PersonaCard
|
||||
item={item}
|
||||
index={index}
|
||||
isLock={item.isLocked}
|
||||
height={itemHeight}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
[itemHeight],
|
||||
);
|
||||
|
||||
const getItemLayout = useCallback(
|
||||
@@ -108,26 +298,15 @@ const FeatureCarousel = ({
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
listRef.current == null ||
|
||||
typeof activeIndex !== "number" ||
|
||||
activeIndex < 0 ||
|
||||
activeIndex >= carouselItems.length
|
||||
) {
|
||||
return;
|
||||
const snapOffsets = useMemo(() => {
|
||||
if (!itemHeight || !isWeb) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
pendingScrollRef.current = true;
|
||||
listRef.current.scrollToIndex({ index: activeIndex, animated: true });
|
||||
} catch (_error) {
|
||||
pendingScrollRef.current = false;
|
||||
// Ignore scroll errors when list is not ready yet.
|
||||
}
|
||||
}, [activeIndex, carouselItems.length]);
|
||||
return carouselItems.map((_, index) => index * itemHeight);
|
||||
}, [carouselItems.length, isWeb, itemHeight]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<View style={[styles.container, style]} onLayout={handleLayout}>
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={carouselItems}
|
||||
@@ -137,16 +316,22 @@ const FeatureCarousel = ({
|
||||
viewabilityConfig={viewabilityConfig.current}
|
||||
getItemLayout={getItemLayout}
|
||||
showsVerticalScrollIndicator={false}
|
||||
pagingEnabled
|
||||
pagingEnabled={!isWeb}
|
||||
bounces={false}
|
||||
overScrollMode="never"
|
||||
initialNumToRender={1}
|
||||
maxToRenderPerBatch={2}
|
||||
windowSize={3}
|
||||
scrollEventThrottle={16}
|
||||
snapToAlignment="start"
|
||||
snapToInterval={itemHeight}
|
||||
decelerationRate="fast"
|
||||
snapToAlignment={isWeb ? undefined : "start"}
|
||||
snapToInterval={!isWeb && itemHeight ? itemHeight : undefined}
|
||||
snapToOffsets={snapOffsets}
|
||||
disableIntervalMomentum={!isWeb}
|
||||
decelerationRate={!isWeb ? "fast" : undefined}
|
||||
style={styles.list}
|
||||
contentContainerStyle={styles.listContent}
|
||||
onScroll={isWeb ? handleScroll : undefined}
|
||||
onMomentumScrollEnd={handleScrollEnd}
|
||||
onScrollEndDrag={isWeb ? handleScrollEnd : undefined}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -162,11 +347,4 @@ const styles = StyleSheet.create({
|
||||
list: {
|
||||
flex: 1,
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
cardRight: {
|
||||
flexDirection: "row-reverse",
|
||||
alignItems: "center",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,8 +14,6 @@ import { FONT_FAMILY } from "../../styles/Fonts";
|
||||
import GradientButton from "../GradientButton";
|
||||
|
||||
const BUTTON_BLUR_INTENSITY = 20;
|
||||
const ROW_BLUR_INTENSITY = 5;
|
||||
const ROW_BLUR_SELECTED_INTENSITY = 45;
|
||||
|
||||
const defaultFormatDate = (timestamp) => {
|
||||
try {
|
||||
@@ -191,7 +189,6 @@ export default ProjectDropDown;
|
||||
const ProjectRow = ({
|
||||
project,
|
||||
formatDate,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onModify,
|
||||
isTitle = false,
|
||||
@@ -252,13 +249,7 @@ const ProjectRow = ({
|
||||
source={coverUri ? { uri: coverUri } : img.placeholder}
|
||||
style={styles.projectImage}
|
||||
/>
|
||||
<BlurView
|
||||
intensity={
|
||||
isSelected ? ROW_BLUR_SELECTED_INTENSITY : ROW_BLUR_INTENSITY
|
||||
}
|
||||
tint="dark"
|
||||
style={styles.projectInfo}
|
||||
>
|
||||
<BlurView tint="dark" style={styles.projectInfo}>
|
||||
<View style={styles.projectTexts}>
|
||||
<Text style={styles.projectTitle} numberOfLines={1}>
|
||||
{title}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Text, useWindowDimensions, View } from "react-native";
|
||||
import { Platform, Text, useWindowDimensions, View } from "react-native";
|
||||
import { Image } from "expo-image";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { gutters, Palette } from "../../../styles";
|
||||
@@ -7,16 +7,17 @@ import { FONT_FAMILY } from "../../../styles/Fonts";
|
||||
import FontAwesome from "@expo/vector-icons/FontAwesome";
|
||||
import palette from "../../../styles/Palette";
|
||||
|
||||
export default function PersonaCard({ item, isLock = false }) {
|
||||
export default function PersonaCard({ item, isLock = false, height }) {
|
||||
const { height: windowHeight } = useWindowDimensions();
|
||||
const baseHeight = Math.max(height ?? windowHeight, 1);
|
||||
const cardHeight = Platform.OS === "web" ? Math.round(baseHeight) : baseHeight;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: windowHeight,
|
||||
marginTop: -20,
|
||||
height: cardHeight,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
|
||||
@@ -18,7 +18,6 @@ export default function PersonaCard({ item, index, isLock = true }) {
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
height: windowHeight,
|
||||
marginTop: 50,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
|
||||
+49
-48
@@ -1,32 +1,32 @@
|
||||
import { GoogleSigninButton } from '@react-native-google-signin/google-signin';
|
||||
import * as AuthSession from 'expo-auth-session';
|
||||
import * as GoogleAuth from 'expo-auth-session/providers/google';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Pressable, Text, View } from 'react-native';
|
||||
import { useGlobal } from 'reactn';
|
||||
import { background } from '../assets';
|
||||
import GradientButton from '../components/GradientButton.js';
|
||||
import { Input } from '../components/Input.js';
|
||||
import ItemContainer from '../components/ItemContainer/ItemContainer.js';
|
||||
import firebase, { usersRef } from '../config/firebase';
|
||||
import { GoogleSigninButton } from "@react-native-google-signin/google-signin";
|
||||
import * as AuthSession from "expo-auth-session";
|
||||
import * as GoogleAuth from "expo-auth-session/providers/google";
|
||||
import * as WebBrowser from "expo-web-browser";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { useGlobal } from "reactn";
|
||||
import { background } from "../assets";
|
||||
import GradientButton from "../components/GradientButton.js";
|
||||
import { Input } from "../components/Input.js";
|
||||
import firebase, { usersRef } from "../config/firebase";
|
||||
import {
|
||||
GOOGLE_ANDROID_CLIENT_ID,
|
||||
GOOGLE_IOS_CLIENT_ID,
|
||||
GOOGLE_WEB_CLIENT_ID,
|
||||
} from '../data/keys';
|
||||
import { isWeb } from '../hooks/useLayoutType';
|
||||
import Page from '../layouts/Page.js';
|
||||
import { Routes } from '../navigation';
|
||||
import { navigate } from '../navigation/NavigationService.js';
|
||||
import { FONT_FAMILY } from '../styles/Fonts.js';
|
||||
import Palette from '../styles/Palette.js';
|
||||
} from "../data/keys";
|
||||
import { isWeb } from "../hooks/useLayoutType";
|
||||
import Page from "../layouts/Page.js";
|
||||
import { Routes } from "../navigation";
|
||||
import { navigate } from "../navigation/NavigationService.js";
|
||||
import { FONT_FAMILY } from "../styles/Fonts.js";
|
||||
import Palette from "../styles/Palette.js";
|
||||
import ItemContainer from "../components/ItemContainer/ItemContainer";
|
||||
|
||||
export default ({ navigation }) => {
|
||||
WebBrowser.maybeCompleteAuthSession();
|
||||
const [, setTooltip] = useGlobal('_tooltip');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [, setTooltip] = useGlobal("_tooltip");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Let the provider compute a compliant redirect URI for native (com.googleusercontent.apps.<client-id>:/oauth2redirect)
|
||||
// Avoid forcing a custom scheme like musicland:// which Google can reject for native apps.
|
||||
@@ -39,12 +39,12 @@ export default ({ navigation }) => {
|
||||
// Use Authorization Code + PKCE to comply with Google OAuth for native apps
|
||||
responseType: AuthSession.ResponseType.Code,
|
||||
usePKCE: true,
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
scopes: ["openid", "profile", "email"],
|
||||
});
|
||||
|
||||
const afterLoginNavigate = async () => {
|
||||
const uid = firebase.auth().currentUser?.uid;
|
||||
if (!uid) throw new Error('Aucun utilisateur après connexion');
|
||||
if (!uid) throw new Error("Aucun utilisateur après connexion");
|
||||
const snap = await usersRef.doc(uid).get();
|
||||
const hasUserName = !!snap.data()?.userName;
|
||||
if (hasUserName) {
|
||||
@@ -60,8 +60,8 @@ export default ({ navigation }) => {
|
||||
await firebase.auth().signInWithEmailAndPassword(email.trim(), password);
|
||||
await afterLoginNavigate();
|
||||
} catch (e) {
|
||||
console.log('Login error', e?.message);
|
||||
setTooltip({ text: e?.message || 'Connexion impossible', type: 'error' });
|
||||
console.log("Login error", e?.message);
|
||||
setTooltip({ text: e?.message || "Connexion impossible", type: "error" });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -72,24 +72,24 @@ export default ({ navigation }) => {
|
||||
setLoading(true);
|
||||
if (isWeb) {
|
||||
const provider = new firebase.auth.GoogleAuthProvider();
|
||||
provider.addScope('profile');
|
||||
provider.addScope('email');
|
||||
provider.addScope("profile");
|
||||
provider.addScope("email");
|
||||
await firebase.auth().signInWithPopup(provider);
|
||||
await afterLoginNavigate();
|
||||
setLoading(false);
|
||||
} else {
|
||||
// Use defaults from the request; don't override with proxy here
|
||||
const result = await promptAsync();
|
||||
if (result?.type !== 'success') {
|
||||
if (result?.type !== "success") {
|
||||
// Cancelled or errored during the browser flow
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Google Login error', e?.message);
|
||||
console.log("Google Login error", e?.message);
|
||||
setTooltip({
|
||||
text: e?.message || 'Connexion Google impossible. Merci de réessayer.',
|
||||
type: 'error',
|
||||
text: e?.message || "Connexion Google impossible. Merci de réessayer.",
|
||||
type: "error",
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export default ({ navigation }) => {
|
||||
useEffect(() => {
|
||||
const handleNativeGoogleResponse = async () => {
|
||||
try {
|
||||
if (response?.type === 'success') {
|
||||
if (response?.type === "success") {
|
||||
// If using Expo proxy, tokens can be present already.
|
||||
let idToken =
|
||||
response?.authentication?.idToken || response?.params?.id_token;
|
||||
@@ -114,13 +114,13 @@ export default ({ navigation }) => {
|
||||
const clientId = request?.clientId;
|
||||
const discovery = {
|
||||
authorizationEndpoint:
|
||||
'https://accounts.google.com/o/oauth2/v2/auth',
|
||||
tokenEndpoint: 'https://oauth2.googleapis.com/token',
|
||||
revocationEndpoint: 'https://oauth2.googleapis.com/revoke',
|
||||
"https://accounts.google.com/o/oauth2/v2/auth",
|
||||
tokenEndpoint: "https://oauth2.googleapis.com/token",
|
||||
revocationEndpoint: "https://oauth2.googleapis.com/revoke",
|
||||
};
|
||||
|
||||
// Light debug: helps identify redirect/client mismatches in dev.
|
||||
console.log('Google token exchange', {
|
||||
console.log("Google token exchange", {
|
||||
clientId,
|
||||
redirectUri: request?.redirectUri,
|
||||
hasCodeVerifier: !!request?.codeVerifier,
|
||||
@@ -139,7 +139,7 @@ export default ({ navigation }) => {
|
||||
}
|
||||
|
||||
if (!idToken)
|
||||
throw new Error('Jeton Google manquant (échange/retour)');
|
||||
throw new Error("Jeton Google manquant (échange/retour)");
|
||||
|
||||
const credential =
|
||||
firebase.auth.GoogleAuthProvider.credential(idToken);
|
||||
@@ -147,10 +147,10 @@ export default ({ navigation }) => {
|
||||
await afterLoginNavigate();
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Google native sign-in error', e?.message);
|
||||
console.log("Google native sign-in error", e?.message);
|
||||
setTooltip({
|
||||
text: e?.message || 'Connexion Google impossible',
|
||||
type: 'error',
|
||||
text: e?.message || "Connexion Google impossible",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -165,13 +165,14 @@ export default ({ navigation }) => {
|
||||
|
||||
return (
|
||||
<Page
|
||||
backgroundImg={background.homeBG}
|
||||
width={isWeb ? 600 : null}
|
||||
backgroundImg={isWeb ? background.loginBgWeb : background.homeBG}
|
||||
headerType="NAVIGATION"
|
||||
title="Connexion"
|
||||
hideBackButton
|
||||
>
|
||||
<View style={{ flex: 1, paddingTop: 20 }}>
|
||||
<ItemContainer height={600} disableKeyboardHeight>
|
||||
<ItemContainer height={600} width={800} disableKeyboardHeight>
|
||||
<View style={{ gap: 30, paddingTop: 5, paddingHorizontal: 5 }}>
|
||||
<View style={{ gap: 2 }}>
|
||||
<Text
|
||||
@@ -181,7 +182,7 @@ export default ({ navigation }) => {
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
}}
|
||||
>
|
||||
Bonjour !
|
||||
{"Bonjour !"}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
@@ -228,8 +229,8 @@ export default ({ navigation }) => {
|
||||
<GradientButton
|
||||
title="Se connecter"
|
||||
containerStyle={{
|
||||
width: '80%',
|
||||
alignSelf: 'center',
|
||||
width: "80%",
|
||||
alignSelf: "center",
|
||||
}}
|
||||
onPress={onLogin}
|
||||
disabled={loading || !email || !password}
|
||||
@@ -252,7 +253,7 @@ export default ({ navigation }) => {
|
||||
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
@@ -264,7 +265,7 @@ export default ({ navigation }) => {
|
||||
fontFamily: FONT_FAMILY.HelveticaNeueRegular,
|
||||
}}
|
||||
>
|
||||
Pas encore de compte?{' '}
|
||||
Pas encore de compte?{" "}
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: FONT_FAMILY.InterSemiBold,
|
||||
|
||||
@@ -65,7 +65,7 @@ const Profile = () => {
|
||||
setFollowers(
|
||||
Array.isArray(currentUserData?.followedBy)
|
||||
? currentUserData.followedBy.length
|
||||
: 0
|
||||
: 0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -263,7 +263,12 @@ const Profile = () => {
|
||||
// }
|
||||
>
|
||||
{isWeb && (
|
||||
<Pressable onPress={() => SheetManager.show("ProfileSettings")}>
|
||||
<Pressable
|
||||
style={{
|
||||
zIndex: 2,
|
||||
}}
|
||||
onPress={() => SheetManager.show("ProfileSettings")}
|
||||
>
|
||||
<Image
|
||||
source={icons.more}
|
||||
style={{
|
||||
|
||||
@@ -9,13 +9,15 @@ import BorderGradientButton from "../components/BorderGradientButton";
|
||||
import { navigate } from "../navigation/NavigationService";
|
||||
import { Routes } from "../navigation";
|
||||
import ItemContainer from "../components/ItemContainer/ItemContainer";
|
||||
import { isWeb } from "../hooks/useLayoutType";
|
||||
|
||||
const Register = () => {
|
||||
const [email, setEmail] = useState("");
|
||||
|
||||
return (
|
||||
<Page
|
||||
backgroundImg={background.homeBG}
|
||||
width={isWeb ? 600 : null}
|
||||
backgroundImg={isWeb ? background.loginBgWeb : background.homeBG}
|
||||
headerType="NAVIGATION"
|
||||
title="Inscription"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user