Initial commit - modstagram-next

This commit is contained in:
root
2026-07-05 13:59:41 +03:30
commit 11e73da693
859 changed files with 117640 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
export function dedupeChatMessages(messages: ChatMessage[]): ChatMessage[] {
const seen = new Set<string>();
const result: ChatMessage[] = [];
for (const m of messages) {
if (!m) continue;
const idKey =
m._id && /^[a-f\d]{24}$/i.test(m._id)
? m._id
: `${m.senderId}|${m.content}|${m.file}|${m.createdAt}`;
if (seen.has(idKey)) continue;
seen.add(idKey);
result.push(m);
}
return result;
}

View File

@@ -0,0 +1,41 @@
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
const IMAGE_EXT = ["jpg", "jpeg", "png", "webp", "gif", "avif"];
const VIDEO_EXT = ["mp4", "webm", "mov", "m4v"];
const VOICE_EXT = ["mp3", "wav", "aac", "m4a", "ogg", "opus", "webm"];
const DOC_EXT = ["pdf", "zip", "rar", "doc", "docx", "xls", "xlsx", "txt"];
/** Reliable media type — voice never shown as video */
export function detectChatFileType(
fileUrl: string,
declared?: ChatMessage["fileType"]
): ChatMessage["fileType"] | null {
if (declared === "location") return "location";
if (declared === "voice") return "voice";
if (!fileUrl) return declared ?? null;
const lower = fileUrl.toLowerCase();
const ext = lower.split(".").pop()?.split("?")[0] || "";
if (declared === "image" || IMAGE_EXT.includes(ext)) return "image";
if (declared === "voice") return "voice";
if (VOICE_EXT.includes(ext) && ext !== "webm") return "voice";
if (ext === "webm") {
if (
lower.includes("voice") ||
lower.includes("audio") ||
declared === "voice"
) {
return "voice";
}
if (declared === "video" || lower.includes("video")) return "video";
return "voice";
}
if (declared === "video" || VIDEO_EXT.includes(ext)) return "video";
if (declared === "file" || DOC_EXT.includes(ext)) return "file";
if (IMAGE_EXT.includes(ext)) return "image";
if (["mp3", "wav", "aac", "m4a", "opus"].includes(ext)) return "voice";
if (VIDEO_EXT.includes(ext)) return "video";
return "file";
}

View File

@@ -0,0 +1,36 @@
/** Bubble time: hours, minutes, seconds only (no date) */
export function formatBubbleTime(createdAt?: string | null): string {
if (!createdAt) {
return new Date().toLocaleTimeString("fa-IR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
const raw = String(createdAt).trim();
// Jalali datetime "1403-01-15 14:30:45" or ISO
const timePart = raw.includes(" ")
? raw.split(" ").pop()
: raw.length <= 8 && raw.includes(":")
? raw
: null;
if (timePart && /^\d{1,2}:\d{2}(:\d{2})?$/.test(timePart)) {
const parts = timePart.split(":");
if (parts.length === 2) return `${parts[0]}:${parts[1]}:00`;
return timePart;
}
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) {
return d.toLocaleTimeString("fa-IR", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
return raw;
}

View File

@@ -0,0 +1,67 @@
import type { ChatMessage } from "@/components/chat/ChatMessageCard";
export type MessageGroup =
| { type: "date"; label: string; key: string }
| { type: "message"; message: ChatMessage; key: string };
function normalizeDateInput(isoOrTime?: string | null): string {
if (isoOrTime == null || String(isoOrTime).trim() === "") {
return new Date().toISOString();
}
return String(isoOrTime);
}
function formatDateLabel(isoOrTime?: string | null): string {
const raw = normalizeDateInput(isoOrTime);
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) {
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (d.toDateString() === today.toDateString()) return "امروز";
if (d.toDateString() === yesterday.toDateString()) return "دیروز";
return d.toLocaleDateString("fa-IR", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
});
}
const fallback = raw.split(" ")[0];
return fallback || "امروز";
}
function dayKey(isoOrTime?: string | null): string {
const raw = normalizeDateInput(isoOrTime);
const d = new Date(raw);
if (!Number.isNaN(d.getTime())) {
return d.toDateString();
}
return raw.split(" ")[0] || "unknown-day";
}
export function groupMessagesByDate(messages: ChatMessage[]): MessageGroup[] {
const groups: MessageGroup[] = [];
let lastDay = "";
messages.forEach((msg, i) => {
if (!msg) return;
const dk = dayKey(msg.createdAt);
if (dk !== lastDay) {
lastDay = dk;
groups.push({
type: "date",
label: formatDateLabel(msg.createdAt),
key: `date-${dk}`,
});
}
groups.push({
type: "message",
message: msg,
key: msg._id || `m-${i}`,
});
});
return groups;
}

33
src/lib/i18n.ts Normal file
View File

@@ -0,0 +1,33 @@
"use client";
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import HttpApi from "i18next-http-backend";
i18n
.use(HttpApi)
.use(LanguageDetector)
.use(initReactI18next)
.init({
fallbackLng: "en",
supportedLngs: [
"en", "fa", "ar", "zh", "hi", "es", "sw", "el", "sr",
"fr", "ru", "bn", "pt", "id", "de", "ja", "te", "tr"
],
defaultNS: "common",
ns: ["common"],
debug: process.env.NODE_ENV === "development",
detection: {
order: ["cookie", "localStorage", "navigator", "htmlTag"],
caches: ["cookie"],
},
backend: {
loadPath: "/locales/{{lng}}/{{ns}}.json",
},
react: {
useSuspense: false,
},
});
export default i18n;

17
src/lib/media/index.ts Normal file
View File

@@ -0,0 +1,17 @@
import { optimizeImageToWebP, optimizeImages } from "./optimizeImage";
import { optimizeVideoToMp4 } from "./optimizeVideo";
export async function optimizeMediaFile(
file: File,
onProgress?: (pct: number) => void
): Promise<File> {
if (file.type.startsWith("image/")) {
return optimizeImageToWebP(file);
}
if (file.type.startsWith("video/")) {
return optimizeVideoToMp4(file, onProgress);
}
return file;
}
export { optimizeImageToWebP, optimizeImages, optimizeVideoToMp4 };

View File

@@ -0,0 +1,33 @@
/** Convert user images to WebP before upload (client-side) */
const MAX_EDGE = 1920;
const WEBP_QUALITY = 0.85;
export async function optimizeImageToWebP(file: File): Promise<File> {
if (!file.type.startsWith("image/")) return file;
if (file.type === "image/webp" && file.size < 2 * 1024 * 1024) return file;
const bitmap = await createImageBitmap(file);
const scale = Math.min(1, MAX_EDGE / Math.max(bitmap.width, bitmap.height));
const w = Math.round(bitmap.width * scale);
const h = Math.round(bitmap.height * scale);
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
if (!ctx) return file;
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
const blob = await new Promise<Blob | null>((resolve) =>
canvas.toBlob(resolve, "image/webp", WEBP_QUALITY)
);
if (!blob) return file;
const baseName = file.name.replace(/\.[^.]+$/, "") || "image";
return new File([blob], `${baseName}.webp`, { type: "image/webp" });
}
export async function optimizeImages(files: File[]): Promise<File[]> {
return Promise.all(files.map((f) => optimizeImageToWebP(f)));
}

View File

@@ -0,0 +1,88 @@
import { FFmpeg } from "@ffmpeg/ffmpeg";
import { fetchFile, toBlobURL } from "@ffmpeg/util";
let ffmpegInstance: FFmpeg | null = null;
let loadPromise: Promise<void> | null = null;
async function getFfmpeg(): Promise<FFmpeg> {
if (ffmpegInstance?.loaded) return ffmpegInstance;
if (!loadPromise) {
loadPromise = (async () => {
const ffmpeg = new FFmpeg();
const baseURL = "https://unpkg.com/@ffmpeg/core@0.12.6/dist/esm";
await ffmpeg.load({
coreURL: await toBlobURL(`${baseURL}/ffmpeg-core.js`, "text/javascript"),
wasmURL: await toBlobURL(
`${baseURL}/ffmpeg-core.wasm`,
"application/wasm"
),
});
ffmpegInstance = ffmpeg;
})();
}
await loadPromise;
return ffmpegInstance!;
}
const MAX_VIDEO_EDGE = 1080;
/**
* Transcode to MP4 H.264 (Instagram-style) — lazy-loads ffmpeg.wasm on first use.
*/
export async function optimizeVideoToMp4(
file: File,
onProgress?: (pct: number) => void
): Promise<File> {
if (!file.type.startsWith("video/")) return file;
if (
file.type === "video/mp4" &&
file.size < 40 * 1024 * 1024 &&
!file.name.toLowerCase().includes(".mov")
) {
return file;
}
const ffmpeg = await getFfmpeg();
if (onProgress) {
ffmpeg.on("progress", ({ progress }) => {
onProgress(Math.min(99, Math.round((progress ?? 0) * 100)));
});
}
const inputName = "input" + (file.name.match(/\.\w+$/)?.[0] || ".mp4");
const outputName = "output.mp4";
await ffmpeg.writeFile(inputName, await fetchFile(file));
await ffmpeg.exec([
"-i",
inputName,
"-vf",
`scale='min(${MAX_VIDEO_EDGE},iw)':-2`,
"-c:v",
"libx264",
"-preset",
"fast",
"-crf",
"28",
"-c:a",
"aac",
"-b:a",
"128k",
"-movflags",
"+faststart",
"-y",
outputName,
]);
const data = await ffmpeg.readFile(outputName);
await ffmpeg.deleteFile(inputName);
await ffmpeg.deleteFile(outputName);
const blob = new Blob([data as BlobPart], { type: "video/mp4" });
const baseName = file.name.replace(/\.[^.]+$/, "") || "video";
onProgress?.(100);
return new File([blob], `${baseName}.mp4`, { type: "video/mp4" });
}

21
src/lib/settings.ts Normal file
View File

@@ -0,0 +1,21 @@
export const languages = [
{ code: 'en', label: 'English', dir: 'ltr' },
{ code: 'fa', label: 'فارسی', dir: 'rtl' },
{ code: 'ar', label: 'العربية', dir: 'rtl' },
{ code: 'zh', label: '中文', dir: 'ltr' },
{ code: 'hi', label: 'हिन्दी', dir: 'ltr' },
{ code: 'es', label: 'Español', dir: 'ltr' },
{ code: 'fr', label: 'Français', dir: 'ltr' },
{ code: 'ru', label: 'Русский', dir: 'ltr' },
{ code: 'bn', label: 'বাংলা', dir: 'ltr' },
{ code: 'pt', label: 'Português', dir: 'ltr' },
{ code: 'id', label: 'Bahasa', dir: 'ltr' },
{ code: 'de', label: 'Deutsch', dir: 'ltr' },
{ code: 'ja', label: '日本語', dir: 'ltr' },
{ code: 'te', label: 'తెలుగు', dir: 'ltr' },
{ code: 'tr', label: 'Türkçe', dir: 'ltr' },
{ code: 'sw', label: 'Swahili', dir: 'ltr' },
{ code: 'el', label: 'Ελληνικά', dir: 'ltr' },
{ code: 'sr', label: 'Српски', dir: 'ltr' },
];

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}