Add trait-based hide rules and searchable block list
This commit is contained in:
@@ -25,6 +25,10 @@ import { IdVerificationModal } from "@/components/IdVerificationModal";
|
|||||||
import { LocalGuidePanel } from "@/components/LocalGuidePanel";
|
import { LocalGuidePanel } from "@/components/LocalGuidePanel";
|
||||||
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
|
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
|
||||||
import { useAccess, useFeedPreferences, useAudienceView } from "@/components/Providers";
|
import { useAccess, useFeedPreferences, useAudienceView } from "@/components/Providers";
|
||||||
|
import { usePrivacy } from "@/contexts/PrivacyContext";
|
||||||
|
import HideFromTraitsPanel from "@/components/HideFromTraitsPanel";
|
||||||
|
import BlockListPanel from "@/components/BlockListPanel";
|
||||||
|
import { BODY_TYPE_OPTIONS } from "@/lib/privacy-rules";
|
||||||
import {
|
import {
|
||||||
US_STATES,
|
US_STATES,
|
||||||
getUserState,
|
getUserState,
|
||||||
@@ -39,6 +43,15 @@ export default function ProfilePage() {
|
|||||||
const { capabilities } = useAccess();
|
const { capabilities } = useAccess();
|
||||||
const { setFeedInterests } = useFeedPreferences();
|
const { setFeedInterests } = useFeedPreferences();
|
||||||
const { audienceView, setAudienceView } = useAudienceView();
|
const { audienceView, setAudienceView } = useAudienceView();
|
||||||
|
const {
|
||||||
|
hideRules,
|
||||||
|
blockList,
|
||||||
|
addHideRule,
|
||||||
|
removeHideRule,
|
||||||
|
unblockProfile,
|
||||||
|
unblockByUsername,
|
||||||
|
unblockAll,
|
||||||
|
} = usePrivacy();
|
||||||
const [authOpen, setAuthOpen] = useState(false);
|
const [authOpen, setAuthOpen] = useState(false);
|
||||||
const [idModalOpen, setIdModalOpen] = useState(false);
|
const [idModalOpen, setIdModalOpen] = useState(false);
|
||||||
const [isAnonymous, setIsAnonymous] = useState(true);
|
const [isAnonymous, setIsAnonymous] = useState(true);
|
||||||
@@ -54,6 +67,10 @@ export default function ProfilePage() {
|
|||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [community, setCommunity] = useState<AudienceCommunity>("gay-men");
|
const [community, setCommunity] = useState<AudienceCommunity>("gay-men");
|
||||||
const [seeking, setSeeking] = useState<SeekingPreference | null>(null);
|
const [seeking, setSeeking] = useState<SeekingPreference | null>(null);
|
||||||
|
const [heightFt, setHeightFt] = useState("");
|
||||||
|
const [heightIn, setHeightIn] = useState("");
|
||||||
|
const [weightLb, setWeightLb] = useState("");
|
||||||
|
const [bodyType, setBodyType] = useState("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (profile) {
|
if (profile) {
|
||||||
@@ -70,6 +87,12 @@ export default function ProfilePage() {
|
|||||||
setState(profile.state || getUserState() || "");
|
setState(profile.state || getUserState() || "");
|
||||||
setCommunity(profile.community ?? resolveProfileCommunity(profile));
|
setCommunity(profile.community ?? resolveProfileCommunity(profile));
|
||||||
setSeeking(profile.seeking ?? null);
|
setSeeking(profile.seeking ?? null);
|
||||||
|
if (profile.height_in != null) {
|
||||||
|
setHeightFt(String(Math.floor(profile.height_in / 12)));
|
||||||
|
setHeightIn(String(profile.height_in % 12));
|
||||||
|
}
|
||||||
|
setWeightLb(profile.weight_lb?.toString() || "");
|
||||||
|
setBodyType(profile.body_type || "");
|
||||||
} else {
|
} else {
|
||||||
const local = localStorage.getItem("eg_profile");
|
const local = localStorage.getItem("eg_profile");
|
||||||
setState(getUserState() || "");
|
setState(getUserState() || "");
|
||||||
@@ -86,6 +109,12 @@ export default function ProfilePage() {
|
|||||||
setStiStatus(p.stiStatus || "");
|
setStiStatus(p.stiStatus || "");
|
||||||
if (p.community) setCommunity(p.community);
|
if (p.community) setCommunity(p.community);
|
||||||
if (p.seeking) setSeeking(p.seeking);
|
if (p.seeking) setSeeking(p.seeking);
|
||||||
|
if (typeof p.heightIn === "number") {
|
||||||
|
setHeightFt(String(Math.floor(p.heightIn / 12)));
|
||||||
|
setHeightIn(String(p.heightIn % 12));
|
||||||
|
}
|
||||||
|
if (typeof p.weightLb === "number") setWeightLb(String(p.weightLb));
|
||||||
|
if (typeof p.bodyType === "string") setBodyType(p.bodyType);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
@@ -107,6 +136,12 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
if (state) setUserState(state);
|
if (state) setUserState(state);
|
||||||
|
|
||||||
|
const ft = parseInt(heightFt, 10);
|
||||||
|
const inches = parseInt(heightIn, 10);
|
||||||
|
const heightTotal =
|
||||||
|
!Number.isNaN(ft) && !Number.isNaN(inches) ? ft * 12 + inches : null;
|
||||||
|
const weight = weightLb.trim() ? parseInt(weightLb, 10) : null;
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
display_name: isAnonymous ? null : displayName || null,
|
display_name: isAnonymous ? null : displayName || null,
|
||||||
age: parseInt(age) || null,
|
age: parseInt(age) || null,
|
||||||
@@ -125,6 +160,9 @@ export default function ProfilePage() {
|
|||||||
city: process.env.NEXT_PUBLIC_DEFAULT_CITY || "Fort Lauderdale",
|
city: process.env.NEXT_PUBLIC_DEFAULT_CITY || "Fort Lauderdale",
|
||||||
lat: parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224"),
|
lat: parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LAT || "26.1224"),
|
||||||
lng: parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373"),
|
lng: parseFloat(process.env.NEXT_PUBLIC_DEFAULT_LNG || "-80.1373"),
|
||||||
|
height_in: heightTotal,
|
||||||
|
weight_lb: weight,
|
||||||
|
body_type: bodyType || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
@@ -144,6 +182,9 @@ export default function ProfilePage() {
|
|||||||
isAnonymous: data.is_anonymous,
|
isAnonymous: data.is_anonymous,
|
||||||
community: data.community,
|
community: data.community,
|
||||||
seeking: data.seeking,
|
seeking: data.seeking,
|
||||||
|
heightIn: data.height_in,
|
||||||
|
weightLb: data.weight_lb,
|
||||||
|
bodyType: data.body_type,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
setMessage("Profile saved locally. Sign up free for cloud sync.");
|
setMessage("Profile saved locally. Sign up free for cloud sync.");
|
||||||
@@ -212,11 +253,14 @@ export default function ProfilePage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card className="border-amber-500/20">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-lg">Visibility</CardTitle>
|
<CardTitle className="text-lg">Privacy & visibility</CardTitle>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Control who can see you and who you never want to see again.
|
||||||
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label>Browse anonymously</Label>
|
<Label>Browse anonymously</Label>
|
||||||
<Switch checked={isAnonymous} onCheckedChange={setIsAnonymous} />
|
<Switch checked={isAnonymous} onCheckedChange={setIsAnonymous} />
|
||||||
@@ -232,6 +276,19 @@ export default function ProfilePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<HideFromTraitsPanel
|
||||||
|
rules={hideRules}
|
||||||
|
onAdd={addHideRule}
|
||||||
|
onRemove={removeHideRule}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BlockListPanel
|
||||||
|
blocks={blockList}
|
||||||
|
onUnblock={unblockProfile}
|
||||||
|
onUnblockByUsername={unblockByUsername}
|
||||||
|
onUnblockAll={unblockAll}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -338,6 +395,62 @@ export default function ProfilePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1 block">Height</Label>
|
||||||
|
<p className="text-[10px] text-muted-foreground mb-2">
|
||||||
|
Used for hide rules — people matching your traits won't see you if you block them by stats.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={4}
|
||||||
|
max={7}
|
||||||
|
value={heightFt}
|
||||||
|
onChange={(e) => setHeightFt(e.target.value)}
|
||||||
|
placeholder="ft"
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={11}
|
||||||
|
value={heightIn}
|
||||||
|
onChange={(e) => setHeightIn(e.target.value)}
|
||||||
|
placeholder="in"
|
||||||
|
className="h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Weight (lb)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={90}
|
||||||
|
max={400}
|
||||||
|
value={weightLb}
|
||||||
|
onChange={(e) => setWeightLb(e.target.value)}
|
||||||
|
placeholder="e.g. 180"
|
||||||
|
className="mt-1 h-11"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label className="mb-2 block">Body type</Label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{BODY_TYPE_OPTIONS.map((b) => (
|
||||||
|
<Badge
|
||||||
|
key={b}
|
||||||
|
variant={bodyType === b ? "pill-active" : "pill"}
|
||||||
|
className="cursor-pointer capitalize min-h-[36px] px-3 touch-manipulation"
|
||||||
|
onClick={() => setBodyType(bodyType === b ? "" : b)}
|
||||||
|
>
|
||||||
|
{b}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-2 block">Position</Label>
|
<Label className="mb-2 block">Position</Label>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|||||||
145
src/components/BlockListPanel.tsx
Normal file
145
src/components/BlockListPanel.tsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { Ban, Search, UserX, Trash2 } from 'lucide-react';
|
||||||
|
import type { BlockEntry } from '@/lib/block-list';
|
||||||
|
import { displayBlockedName } from '@/lib/block-list';
|
||||||
|
|
||||||
|
interface BlockListPanelProps {
|
||||||
|
blocks: BlockEntry[];
|
||||||
|
onUnblock: (blockedUserId: string) => void;
|
||||||
|
onUnblockByUsername: (username: string) => boolean;
|
||||||
|
onUnblockAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BlockListPanel({
|
||||||
|
blocks,
|
||||||
|
onUnblock,
|
||||||
|
onUnblockByUsername,
|
||||||
|
onUnblockAll,
|
||||||
|
}: BlockListPanelProps) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [usernameInput, setUsernameInput] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (!q) return blocks;
|
||||||
|
return blocks.filter(
|
||||||
|
(b) =>
|
||||||
|
b.username.toLowerCase().includes(q) ||
|
||||||
|
(b.display_name || '').toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}, [blocks, search]);
|
||||||
|
|
||||||
|
const handleUnblockUsername = () => {
|
||||||
|
setMessage('');
|
||||||
|
const ok = onUnblockByUsername(usernameInput);
|
||||||
|
if (ok) {
|
||||||
|
setMessage(`Unblocked @${usernameInput.replace(/^@/, '')}`);
|
||||||
|
setUsernameInput('');
|
||||||
|
} else {
|
||||||
|
setMessage('No block found for that username.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnblockAll = () => {
|
||||||
|
if (blocks.length === 0) return;
|
||||||
|
if (window.confirm(`Unblock all ${blocks.length} people?`)) {
|
||||||
|
onUnblockAll();
|
||||||
|
setMessage('Everyone unblocked.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-zinc-700 bg-zinc-900/60 p-5 space-y-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2 rounded-xl bg-red-500/15">
|
||||||
|
<Ban className="w-5 h-5 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white">Block list</h3>
|
||||||
|
<p className="text-sm text-zinc-400 mt-1">
|
||||||
|
Blocked people can't see you and you won't see them anywhere on ExposedGays.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-500" />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search blocked usernames…"
|
||||||
|
className="w-full pl-10 pr-3 py-2.5 rounded-xl bg-zinc-800 border border-zinc-700 text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={usernameInput}
|
||||||
|
onChange={(e) => setUsernameInput(e.target.value)}
|
||||||
|
placeholder="Unblock by @username"
|
||||||
|
className="flex-1 px-3 py-2 rounded-xl bg-zinc-800 border border-zinc-700 text-white text-sm"
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleUnblockUsername()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleUnblockUsername}
|
||||||
|
className="px-4 py-2 rounded-xl bg-zinc-700 hover:bg-zinc-600 text-white text-sm font-medium"
|
||||||
|
>
|
||||||
|
Unblock
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<p className={`text-xs ${message.includes('No block') ? 'text-amber-400' : 'text-emerald-400'}`}>
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="text-sm text-zinc-500 py-4 text-center">
|
||||||
|
{blocks.length === 0 ? 'Nobody blocked yet.' : 'No matches for that search.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{filtered.map((entry) => (
|
||||||
|
<li
|
||||||
|
key={entry.id}
|
||||||
|
className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-xl bg-zinc-800/80 border border-zinc-700/80"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-white truncate">{displayBlockedName(entry)}</p>
|
||||||
|
<p className="text-[10px] text-zinc-500">
|
||||||
|
Blocked {new Date(entry.blocked_at).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onUnblock(entry.id)}
|
||||||
|
className="shrink-0 flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs text-zinc-400 hover:text-white hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
<UserX className="w-3.5 h-3.5" />
|
||||||
|
Unblock
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{blocks.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleUnblockAll}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-2 rounded-xl border border-red-500/30 text-red-400 hover:bg-red-500/10 text-sm"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Unblock everyone ({blocks.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
Ban,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Share2,
|
Share2,
|
||||||
Video,
|
Video,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
|
import { GuestUpgradeBanner } from "@/components/GuestUpgradeBanner";
|
||||||
import { useAccess } from "@/components/Providers";
|
import { useAccess } from "@/components/Providers";
|
||||||
|
import { usePrivacy } from "@/contexts/PrivacyContext";
|
||||||
import type { Profile } from "@/types";
|
import type { Profile } from "@/types";
|
||||||
import { formatStatsLine, formatDistance } from "@/lib/profile-stats";
|
import { formatStatsLine, formatDistance } from "@/lib/profile-stats";
|
||||||
import { timeAgo } from "@/lib/utils";
|
import { timeAgo } from "@/lib/utils";
|
||||||
@@ -84,7 +86,9 @@ export function CruiserProfileView({
|
|||||||
vanillaMode,
|
vanillaMode,
|
||||||
}: CruiserProfileViewProps) {
|
}: CruiserProfileViewProps) {
|
||||||
const { capabilities, blurExplicit } = useAccess();
|
const { capabilities, blurExplicit } = useAccess();
|
||||||
|
const { blockProfile, isBlocked, unblockProfile } = usePrivacy();
|
||||||
const [tab, setTab] = useState<Tab>("about");
|
const [tab, setTab] = useState<Tab>("about");
|
||||||
|
const [blockMenuOpen, setBlockMenuOpen] = useState(false);
|
||||||
const [activePhoto, setActivePhoto] = useState(0);
|
const [activePhoto, setActivePhoto] = useState(0);
|
||||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||||
@@ -121,9 +125,20 @@ export function CruiserProfileView({
|
|||||||
|
|
||||||
const handleChat = () => {
|
const handleChat = () => {
|
||||||
if (!capabilities.canSendMessages) return;
|
if (!capabilities.canSendMessages) return;
|
||||||
|
if (isBlocked(profile.id)) return;
|
||||||
onChat(profile);
|
onChat(profile);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBlock = () => {
|
||||||
|
if (isBlocked(profile.id)) {
|
||||||
|
unblockProfile(profile.id);
|
||||||
|
} else {
|
||||||
|
blockProfile(profile);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
setBlockMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
const thumbs = photos.slice(0, 5);
|
const thumbs = photos.slice(0, 5);
|
||||||
|
|
||||||
const openLightbox = (index: number) => {
|
const openLightbox = (index: number) => {
|
||||||
@@ -413,9 +428,28 @@ export function CruiserProfileView({
|
|||||||
|
|
||||||
{/* Bottom bar */}
|
{/* Bottom bar */}
|
||||||
<div className="shrink-0 border-t border-white/10 bg-black px-4 py-3 flex items-center justify-between safe-bottom">
|
<div className="shrink-0 border-t border-white/10 bg-black px-4 py-3 flex items-center justify-between safe-bottom">
|
||||||
<button type="button" className="p-2 text-white/60" aria-label="More">
|
<div className="relative">
|
||||||
<MoreHorizontal className="h-5 w-5" />
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
className="p-2 text-white/60 touch-manipulation"
|
||||||
|
aria-label="More"
|
||||||
|
onClick={() => setBlockMenuOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
{blockMenuOpen && (
|
||||||
|
<div className="absolute bottom-full left-0 mb-1 min-w-[160px] rounded-xl border border-white/15 bg-pulse-midnight shadow-lg overflow-hidden z-10">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleBlock}
|
||||||
|
className="w-full flex items-center gap-2 px-4 py-3 text-sm text-left hover:bg-white/10 touch-manipulation"
|
||||||
|
>
|
||||||
|
<Ban className="h-4 w-4 text-red-400" />
|
||||||
|
{isBlocked(profile.id) ? "Unblock" : "Block user"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-3 text-xs text-white/70">
|
<div className="flex items-center gap-3 text-xs text-white/70">
|
||||||
<span>{timeAgo(profile.last_active)}</span>
|
<span>{timeAgo(profile.last_active)}</span>
|
||||||
{profile.distance_miles != null && (
|
{profile.distance_miles != null && (
|
||||||
|
|||||||
249
src/components/HideFromTraitsPanel.tsx
Normal file
249
src/components/HideFromTraitsPanel.tsx
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { EyeOff, Plus, Trash2, Info } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
BODY_TYPE_OPTIONS,
|
||||||
|
buildHideRuleLabel,
|
||||||
|
type HideFromRule,
|
||||||
|
} from '@/lib/privacy-rules';
|
||||||
|
|
||||||
|
interface HideFromTraitsPanelProps {
|
||||||
|
rules: HideFromRule[];
|
||||||
|
onAdd: (rule: Omit<HideFromRule, 'id' | 'created_at'>) => void;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const POSITIONS = ['top', 'vers', 'bottom', 'side', 'oral', 'watching'] as const;
|
||||||
|
|
||||||
|
export default function HideFromTraitsPanel({
|
||||||
|
rules,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
}: HideFromTraitsPanelProps) {
|
||||||
|
const [heightFt, setHeightFt] = useState('5');
|
||||||
|
const [heightIn, setHeightIn] = useState('8');
|
||||||
|
const [weightLb, setWeightLb] = useState('220');
|
||||||
|
const [ageMin, setAgeMin] = useState('');
|
||||||
|
const [ageMax, setAgeMax] = useState('');
|
||||||
|
const [positions, setPositions] = useState<string[]>([]);
|
||||||
|
const [bodyTypes, setBodyTypes] = useState<string[]>([]);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const preview = useMemo(() => {
|
||||||
|
const ft = parseInt(heightFt, 10);
|
||||||
|
const inches = parseInt(heightIn, 10);
|
||||||
|
const weight = parseInt(weightLb, 10);
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (!Number.isNaN(ft) && !Number.isNaN(inches)) {
|
||||||
|
parts.push(`${ft}'${inches}"`);
|
||||||
|
}
|
||||||
|
if (!Number.isNaN(weight)) parts.push(`${weight} lb`);
|
||||||
|
if (ageMin || ageMax) parts.push(`age ${ageMin || '…'}–${ageMax || '…'}`);
|
||||||
|
if (positions.length) parts.push(positions.join(', '));
|
||||||
|
if (bodyTypes.length) parts.push(bodyTypes.join(', '));
|
||||||
|
return parts.length ? parts.join(' · ') : 'Add at least one trait';
|
||||||
|
}, [heightFt, heightIn, weightLb, ageMin, ageMax, positions, bodyTypes]);
|
||||||
|
|
||||||
|
const toggle = (list: string[], value: string, set: (v: string[]) => void) => {
|
||||||
|
set(list.includes(value) ? list.filter((x) => x !== value) : [...list, value]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setError('');
|
||||||
|
const ft = parseInt(heightFt, 10);
|
||||||
|
const inches = parseInt(heightIn, 10);
|
||||||
|
const weight = weightLb.trim() ? parseInt(weightLb, 10) : undefined;
|
||||||
|
const heightTotal =
|
||||||
|
!Number.isNaN(ft) && !Number.isNaN(inches) ? ft * 12 + inches : undefined;
|
||||||
|
|
||||||
|
const hasTrait =
|
||||||
|
heightTotal !== undefined ||
|
||||||
|
weight !== undefined ||
|
||||||
|
ageMin.trim() !== '' ||
|
||||||
|
ageMax.trim() !== '' ||
|
||||||
|
positions.length > 0 ||
|
||||||
|
bodyTypes.length > 0;
|
||||||
|
|
||||||
|
if (!hasTrait) {
|
||||||
|
setError('Pick at least one trait — height, weight, age, position, or body type.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onAdd({
|
||||||
|
height_in: heightTotal,
|
||||||
|
height_tolerance_in: heightTotal !== undefined ? 1 : undefined,
|
||||||
|
weight_lb: weight,
|
||||||
|
weight_tolerance_lb: weight !== undefined ? 10 : undefined,
|
||||||
|
age_min: ageMin.trim() ? parseInt(ageMin, 10) : undefined,
|
||||||
|
age_max: ageMax.trim() ? parseInt(ageMax, 10) : undefined,
|
||||||
|
positions: positions.length ? positions : undefined,
|
||||||
|
body_types: bodyTypes.length ? bodyTypes : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
setPositions([]);
|
||||||
|
setBodyTypes([]);
|
||||||
|
setAgeMin('');
|
||||||
|
setAgeMax('');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border-2 border-amber-500/40 bg-gradient-to-br from-amber-950/30 to-zinc-900/80 p-5 space-y-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2 rounded-xl bg-amber-500/20">
|
||||||
|
<EyeOff className="w-5 h-5 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-white">Who can't see me</h3>
|
||||||
|
<p className="text-sm text-zinc-400 mt-1 leading-relaxed">
|
||||||
|
People who match any rule below will <strong className="text-amber-200">not see you at all</strong> — not on the map, not in chat, not in search. You still see everyone else (unless you block them).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 text-xs text-amber-200/80 bg-amber-500/10 border border-amber-500/20 rounded-lg px-3 py-2">
|
||||||
|
<Info className="w-4 h-4 shrink-0 mt-0.5" />
|
||||||
|
<span>
|
||||||
|
Example: 5'8" and 220 lb hides you from guys around that size. Add multiple rules for different types you're not into.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rules.length > 0 && (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{rules.map((rule) => (
|
||||||
|
<li
|
||||||
|
key={rule.id}
|
||||||
|
className="flex items-center justify-between gap-3 px-3 py-2.5 rounded-xl bg-zinc-800/80 border border-zinc-700"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-zinc-200">{buildHideRuleLabel(rule)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(rule.id)}
|
||||||
|
className="p-1.5 rounded-lg text-zinc-500 hover:text-red-400 hover:bg-red-500/10"
|
||||||
|
aria-label="Remove rule"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-3 pt-2 border-t border-zinc-700/80">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-zinc-500">Add hide rule</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<label className="text-xs text-zinc-500 col-span-3">Height</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={4}
|
||||||
|
max={7}
|
||||||
|
value={heightFt}
|
||||||
|
onChange={(e) => setHeightFt(e.target.value)}
|
||||||
|
placeholder="ft"
|
||||||
|
className="px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={11}
|
||||||
|
value={heightIn}
|
||||||
|
onChange={(e) => setHeightIn(e.target.value)}
|
||||||
|
placeholder="in"
|
||||||
|
className="px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm"
|
||||||
|
/>
|
||||||
|
<span className="self-center text-xs text-zinc-500">±1 in</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-zinc-500">Weight (lb)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={90}
|
||||||
|
max={400}
|
||||||
|
value={weightLb}
|
||||||
|
onChange={(e) => setWeightLb(e.target.value)}
|
||||||
|
className="w-full mt-1 px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-zinc-600 mt-0.5">±10 lb tolerance</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-zinc-500">Age min</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={ageMin}
|
||||||
|
onChange={(e) => setAgeMin(e.target.value)}
|
||||||
|
className="w-full mt-1 px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-zinc-500">Age max</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={ageMax}
|
||||||
|
onChange={(e) => setAgeMax(e.target.value)}
|
||||||
|
className="w-full mt-1 px-3 py-2 rounded-lg bg-zinc-800 border border-zinc-700 text-white text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-zinc-500 mb-1.5 block">Position</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{POSITIONS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(positions, p, setPositions)}
|
||||||
|
className={`px-2.5 py-1 rounded-full text-xs capitalize ${
|
||||||
|
positions.includes(p)
|
||||||
|
? 'bg-amber-500/30 text-amber-200 border border-amber-500/50'
|
||||||
|
: 'bg-zinc-800 text-zinc-400 border border-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-zinc-500 mb-1.5 block">Body type</label>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{BODY_TYPE_OPTIONS.map((b) => (
|
||||||
|
<button
|
||||||
|
key={b}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(bodyTypes, b, setBodyTypes)}
|
||||||
|
className={`px-2.5 py-1 rounded-full text-xs capitalize ${
|
||||||
|
bodyTypes.includes(b)
|
||||||
|
? 'bg-amber-500/30 text-amber-200 border border-amber-500/50'
|
||||||
|
: 'bg-zinc-800 text-zinc-400 border border-zinc-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{b}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-zinc-500">
|
||||||
|
Preview: <span className="text-zinc-300">{preview}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <p className="text-xs text-red-400">{error}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="w-full flex items-center justify-center gap-2 py-2.5 rounded-xl bg-amber-600 hover:bg-amber-500 text-white text-sm font-semibold"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add hide rule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,10 +15,12 @@ import type { Profile, CruisingSpot, MapFilters, Position } from "@/types";
|
|||||||
import { INTEREST_OPTIONS, profileMatchesInterests } from "@/lib/interests";
|
import { INTEREST_OPTIONS, profileMatchesInterests } from "@/lib/interests";
|
||||||
import { InterestTicker } from "@/components/InterestTicker";
|
import { InterestTicker } from "@/components/InterestTicker";
|
||||||
import { InterestPreviewRail } from "@/components/InterestPreviewRail";
|
import { InterestPreviewRail } from "@/components/InterestPreviewRail";
|
||||||
import { useFeedPreferences, useAudienceView } from "@/components/Providers";
|
import { useFeedPreferences, useAudienceView, useOpenIdVerification, useAccess } from "@/components/Providers";
|
||||||
import { profileMatchesAudienceView } from "@/lib/audience";
|
import { profileMatchesAudienceView } from "@/lib/audience";
|
||||||
import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data";
|
import { MOCK_PROFILES, MOCK_SPOTS } from "@/lib/mock-data";
|
||||||
import { useOpenIdVerification, useAccess } from "@/components/Providers";
|
import { useAuth } from "@/lib/auth/context";
|
||||||
|
import { usePrivacy } from "@/contexts/PrivacyContext";
|
||||||
|
import { filterVisibleProfiles, guestViewerProfile } from "@/lib/visibility";
|
||||||
import { distanceMiles } from "@/lib/profile-stats";
|
import { distanceMiles } from "@/lib/profile-stats";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -52,7 +54,15 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
const { capabilities, blurExplicit } = useAccess();
|
const { capabilities, blurExplicit } = useAccess();
|
||||||
const { feedInterests } = useFeedPreferences();
|
const { feedInterests } = useFeedPreferences();
|
||||||
const { audienceView } = useAudienceView();
|
const { audienceView } = useAudienceView();
|
||||||
|
const { user, profile: authProfile } = useAuth();
|
||||||
|
const { blockList } = usePrivacy();
|
||||||
const [profiles] = useState<Profile[]>(MOCK_PROFILES);
|
const [profiles] = useState<Profile[]>(MOCK_PROFILES);
|
||||||
|
|
||||||
|
const viewer = useMemo(
|
||||||
|
() => (authProfile ? authProfile : guestViewerProfile()),
|
||||||
|
[authProfile]
|
||||||
|
);
|
||||||
|
const viewerId = user?.id ?? "guest";
|
||||||
const [spots] = useState<CruisingSpot[]>(MOCK_SPOTS);
|
const [spots] = useState<CruisingSpot[]>(MOCK_SPOTS);
|
||||||
const [selectedProfile, setSelectedProfile] = useState<Profile | null>(null);
|
const [selectedProfile, setSelectedProfile] = useState<Profile | null>(null);
|
||||||
const [showFilters, setShowFilters] = useState(false);
|
const [showFilters, setShowFilters] = useState(false);
|
||||||
@@ -141,7 +151,7 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const filteredProfiles = useMemo(() => {
|
const filteredProfiles = useMemo(() => {
|
||||||
return profiles.filter((p) => {
|
const base = profiles.filter((p) => {
|
||||||
if (filters.onlineOnly && p.status === "offline") return false;
|
if (filters.onlineOnly && p.status === "offline") return false;
|
||||||
if (p.age && (p.age < filters.ageMin || p.age > filters.ageMax)) return false;
|
if (p.age && (p.age < filters.ageMin || p.age > filters.ageMax)) return false;
|
||||||
if (filters.positions.length && p.position && !filters.positions.includes(p.position))
|
if (filters.positions.length && p.position && !filters.positions.includes(p.position))
|
||||||
@@ -155,17 +165,19 @@ export function CruisingMap({ vanillaMode, onActiveCountChange }: MapProps) {
|
|||||||
if (filters.status.length && !filters.status.includes(p.status)) return false;
|
if (filters.status.length && !filters.status.includes(p.status)) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [profiles, filters, feedInterests, audienceView]);
|
return filterVisibleProfiles(viewer, viewerId, base, blockList);
|
||||||
|
}, [profiles, filters, feedInterests, audienceView, viewer, viewerId, blockList]);
|
||||||
|
|
||||||
const interestMatches = useMemo(() => {
|
const interestMatches = useMemo(() => {
|
||||||
if (!feedInterests.length) return [];
|
if (!feedInterests.length) return [];
|
||||||
return profiles.filter(
|
const base = profiles.filter(
|
||||||
(p) =>
|
(p) =>
|
||||||
p.status !== "offline" &&
|
p.status !== "offline" &&
|
||||||
profileMatchesAudienceView(p, audienceView) &&
|
profileMatchesAudienceView(p, audienceView) &&
|
||||||
profileMatchesInterests(p.kinks, feedInterests)
|
profileMatchesInterests(p.kinks, feedInterests)
|
||||||
);
|
);
|
||||||
}, [profiles, feedInterests, audienceView]);
|
return filterVisibleProfiles(viewer, viewerId, base, blockList);
|
||||||
|
}, [profiles, feedInterests, audienceView, viewer, viewerId, blockList]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onActiveCountChange?.(filteredProfiles.filter((p) => p.status !== "offline").length);
|
onActiveCountChange?.(filteredProfiles.filter((p) => p.status !== "offline").length);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { MobileHeader } from "@/components/MobileHeader";
|
|||||||
import { IdVerificationModal } from "@/components/IdVerificationModal";
|
import { IdVerificationModal } from "@/components/IdVerificationModal";
|
||||||
import { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { AuthProvider, useAuth } from "@/lib/auth/context";
|
import { AuthProvider, useAuth } from "@/lib/auth/context";
|
||||||
|
import { PrivacyProvider } from "@/contexts/PrivacyContext";
|
||||||
import {
|
import {
|
||||||
requiresIdVerification,
|
requiresIdVerification,
|
||||||
getUserState,
|
getUserState,
|
||||||
@@ -259,7 +260,9 @@ export function Providers({ children }: ProvidersProps) {
|
|||||||
<>
|
<>
|
||||||
{!verified && <AgeGate onVerified={() => setVerified(true)} />}
|
{!verified && <AgeGate onVerified={() => setVerified(true)} />}
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<InnerProviders>{children}</InnerProviders>
|
<PrivacyProvider>
|
||||||
|
<InnerProviders>{children}</InnerProviders>
|
||||||
|
</PrivacyProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
155
src/contexts/PrivacyContext.tsx
Normal file
155
src/contexts/PrivacyContext.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import { useAuth } from '@/lib/auth/context';
|
||||||
|
import {
|
||||||
|
addHideFromRule,
|
||||||
|
loadHideFromRules,
|
||||||
|
removeHideFromRule,
|
||||||
|
type HideFromRule,
|
||||||
|
} from '@/lib/privacy-rules';
|
||||||
|
import {
|
||||||
|
blockProfile as blockProfileLib,
|
||||||
|
loadBlockList,
|
||||||
|
unblockAll as unblockAllLib,
|
||||||
|
unblockByUsername as unblockByUsernameLib,
|
||||||
|
unblockProfile as unblockProfileLib,
|
||||||
|
type BlockEntry,
|
||||||
|
} from '@/lib/block-list';
|
||||||
|
import type { Profile } from '@/types';
|
||||||
|
|
||||||
|
interface PrivacyContextValue {
|
||||||
|
hideRules: HideFromRule[];
|
||||||
|
blockList: BlockEntry[];
|
||||||
|
addHideRule: (rule: Omit<HideFromRule, 'id' | 'created_at' | 'label'>) => void;
|
||||||
|
removeHideRule: (id: string) => void;
|
||||||
|
blockProfile: (profile: Profile) => void;
|
||||||
|
unblockProfile: (blockedUserId: string) => void;
|
||||||
|
unblockByUsername: (username: string) => boolean;
|
||||||
|
unblockAll: () => void;
|
||||||
|
isBlocked: (userId: string) => boolean;
|
||||||
|
refresh: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PrivacyContext = createContext<PrivacyContextValue | null>(null);
|
||||||
|
|
||||||
|
export function PrivacyProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const userId = user?.id ?? null;
|
||||||
|
|
||||||
|
const [hideRules, setHideRules] = useState<HideFromRule[]>([]);
|
||||||
|
const [blockList, setBlockList] = useState<BlockEntry[]>([]);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setHideRules(loadHideFromRules(userId));
|
||||||
|
setBlockList(loadBlockList(userId));
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onHide = () => refresh();
|
||||||
|
const onBlock = () => refresh();
|
||||||
|
window.addEventListener('eg-hide-rules', onHide);
|
||||||
|
window.addEventListener('eg-block-list', onBlock);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('eg-hide-rules', onHide);
|
||||||
|
window.removeEventListener('eg-block-list', onBlock);
|
||||||
|
};
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const addHideRule = useCallback(
|
||||||
|
(rule: Omit<HideFromRule, 'id' | 'created_at' | 'label'>) => {
|
||||||
|
const next = addHideFromRule(rule, userId);
|
||||||
|
setHideRules(next);
|
||||||
|
},
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const removeHideRule = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
const next = removeHideFromRule(id, userId);
|
||||||
|
setHideRules(next);
|
||||||
|
},
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const blockProfile = useCallback(
|
||||||
|
(profile: Profile) => {
|
||||||
|
const next = blockProfileLib(profile, userId);
|
||||||
|
setBlockList(next);
|
||||||
|
},
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const unblockProfile = useCallback(
|
||||||
|
(blockedUserId: string) => {
|
||||||
|
const next = unblockProfileLib(blockedUserId, userId);
|
||||||
|
setBlockList(next);
|
||||||
|
},
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const unblockByUsername = useCallback(
|
||||||
|
(username: string) => {
|
||||||
|
const { blocks, removed } = unblockByUsernameLib(username, userId);
|
||||||
|
if (removed) setBlockList(blocks);
|
||||||
|
return !!removed;
|
||||||
|
},
|
||||||
|
[userId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const unblockAll = useCallback(() => {
|
||||||
|
setBlockList(unblockAllLib(userId));
|
||||||
|
}, [userId]);
|
||||||
|
|
||||||
|
const isBlocked = useCallback(
|
||||||
|
(targetId: string) => blockList.some((b) => b.id === targetId),
|
||||||
|
[blockList]
|
||||||
|
);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
hideRules,
|
||||||
|
blockList,
|
||||||
|
addHideRule,
|
||||||
|
removeHideRule,
|
||||||
|
blockProfile,
|
||||||
|
unblockProfile,
|
||||||
|
unblockByUsername,
|
||||||
|
unblockAll,
|
||||||
|
isBlocked,
|
||||||
|
refresh,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
hideRules,
|
||||||
|
blockList,
|
||||||
|
addHideRule,
|
||||||
|
removeHideRule,
|
||||||
|
blockProfile,
|
||||||
|
unblockProfile,
|
||||||
|
unblockByUsername,
|
||||||
|
unblockAll,
|
||||||
|
isBlocked,
|
||||||
|
refresh,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <PrivacyContext.Provider value={value}>{children}</PrivacyContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePrivacy() {
|
||||||
|
const ctx = useContext(PrivacyContext);
|
||||||
|
if (!ctx) throw new Error('usePrivacy must be used within PrivacyProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -54,6 +54,9 @@ function mapDbProfile(row: Record<string, unknown>): Profile {
|
|||||||
orientation: (row.orientation as string) ?? null,
|
orientation: (row.orientation as string) ?? null,
|
||||||
community: (row.community as Profile["community"]) ?? null,
|
community: (row.community as Profile["community"]) ?? null,
|
||||||
seeking: (row.seeking as Profile["seeking"]) ?? null,
|
seeking: (row.seeking as Profile["seeking"]) ?? null,
|
||||||
|
height_in: (row.height_in as number) ?? null,
|
||||||
|
weight_lb: (row.weight_lb as number) ?? null,
|
||||||
|
body_type: (row.body_type as string) ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
112
src/lib/block-list.ts
Normal file
112
src/lib/block-list.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import type { Profile } from "@/types";
|
||||||
|
|
||||||
|
const LS_BLOCKS = "eg_block_list";
|
||||||
|
const LS_BLOCKS_USER = (userId: string) => `eg_block_list_${userId}`;
|
||||||
|
|
||||||
|
export interface BlockEntry {
|
||||||
|
id: string;
|
||||||
|
username: string | null;
|
||||||
|
display_name: string | null;
|
||||||
|
avatar_url: string | null;
|
||||||
|
blocked_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function profileToBlockEntry(p: Profile): BlockEntry {
|
||||||
|
return {
|
||||||
|
id: p.id,
|
||||||
|
username: p.username,
|
||||||
|
display_name: p.display_name,
|
||||||
|
avatar_url: p.avatar_url,
|
||||||
|
blocked_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadBlockList(userId?: string | null): BlockEntry[] {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
try {
|
||||||
|
const key = userId ? LS_BLOCKS_USER(userId) : LS_BLOCKS;
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw) as BlockEntry[];
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveBlockList(blocks: BlockEntry[], userId?: string | null): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const key = userId ? LS_BLOCKS_USER(userId) : LS_BLOCKS;
|
||||||
|
localStorage.setItem(key, JSON.stringify(blocks));
|
||||||
|
window.dispatchEvent(new CustomEvent("eg-block-list", { detail: blocks }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBlocked(
|
||||||
|
profileId: string,
|
||||||
|
blocks: BlockEntry[]
|
||||||
|
): boolean {
|
||||||
|
return blocks.some((b) => b.id === profileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blockProfile(
|
||||||
|
profile: Profile,
|
||||||
|
userId?: string | null
|
||||||
|
): BlockEntry[] {
|
||||||
|
const current = loadBlockList(userId);
|
||||||
|
if (current.some((b) => b.id === profile.id)) return current;
|
||||||
|
const next = [profileToBlockEntry(profile), ...current];
|
||||||
|
saveBlockList(next, userId);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unblockProfile(
|
||||||
|
profileId: string,
|
||||||
|
userId?: string | null
|
||||||
|
): BlockEntry[] {
|
||||||
|
const next = loadBlockList(userId).filter((b) => b.id !== profileId);
|
||||||
|
saveBlockList(next, userId);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unblockByUsername(
|
||||||
|
query: string,
|
||||||
|
userId?: string | null
|
||||||
|
): { blocks: BlockEntry[]; removed: BlockEntry | null } {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return { blocks: loadBlockList(userId), removed: null };
|
||||||
|
const current = loadBlockList(userId);
|
||||||
|
const match = current.find(
|
||||||
|
(b) =>
|
||||||
|
b.username?.toLowerCase() === q ||
|
||||||
|
b.display_name?.toLowerCase() === q
|
||||||
|
);
|
||||||
|
if (!match) return { blocks: current, removed: null };
|
||||||
|
const next = current.filter((b) => b.id !== match.id);
|
||||||
|
saveBlockList(next, userId);
|
||||||
|
return { blocks: next, removed: match };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unblockAll(userId?: string | null): BlockEntry[] {
|
||||||
|
saveBlockList([], userId);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function searchBlockList(
|
||||||
|
blocks: BlockEntry[],
|
||||||
|
query: string
|
||||||
|
): BlockEntry[] {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return blocks;
|
||||||
|
return blocks.filter(
|
||||||
|
(b) =>
|
||||||
|
b.username?.toLowerCase().includes(q) ||
|
||||||
|
b.display_name?.toLowerCase().includes(q) ||
|
||||||
|
b.id.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayBlockedName(entry: BlockEntry): string {
|
||||||
|
if (entry.username) return `@${entry.username}`;
|
||||||
|
if (entry.display_name) return entry.display_name;
|
||||||
|
return `User ${entry.id.slice(0, 8)}`;
|
||||||
|
}
|
||||||
175
src/lib/privacy-rules.ts
Normal file
175
src/lib/privacy-rules.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import type { AudienceCommunity, Position, Profile } from "@/types";
|
||||||
|
|
||||||
|
const LS_HIDE_RULES = "eg_hide_from_rules";
|
||||||
|
const LS_HIDE_RULES_USER = (userId: string) => `eg_hide_from_rules_${userId}`;
|
||||||
|
|
||||||
|
/** People matching ALL set traits in a rule cannot see you on the map, chat, or search. */
|
||||||
|
export interface HideFromRule {
|
||||||
|
id: string;
|
||||||
|
created_at: string;
|
||||||
|
/** Human-readable summary shown in the UI */
|
||||||
|
label: string;
|
||||||
|
height_in?: number | null;
|
||||||
|
height_tolerance_in?: number;
|
||||||
|
weight_lb?: number | null;
|
||||||
|
weight_tolerance_lb?: number;
|
||||||
|
age_min?: number | null;
|
||||||
|
age_max?: number | null;
|
||||||
|
positions?: Position[];
|
||||||
|
body_types?: string[];
|
||||||
|
kinks?: string[];
|
||||||
|
communities?: AudienceCommunity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BODY_TYPE_OPTIONS = [
|
||||||
|
"slim",
|
||||||
|
"fit",
|
||||||
|
"average",
|
||||||
|
"stocky",
|
||||||
|
"bear",
|
||||||
|
"muscular",
|
||||||
|
"chubby",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function formatHeight(inches: number): string {
|
||||||
|
const ft = Math.floor(inches / 12);
|
||||||
|
const inch = inches % 12;
|
||||||
|
return `${ft}'${inch}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseHeightFeetInches(feet: number, inches: number): number {
|
||||||
|
return Math.max(48, Math.min(96, feet * 12 + inches));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHideRuleLabel(draft: Partial<HideFromRule>): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (draft.height_in != null) {
|
||||||
|
const tol = draft.height_tolerance_in ?? 0;
|
||||||
|
parts.push(
|
||||||
|
tol > 0
|
||||||
|
? `${formatHeight(draft.height_in)} (±${tol}")`
|
||||||
|
: formatHeight(draft.height_in)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (draft.weight_lb != null) {
|
||||||
|
const tol = draft.weight_tolerance_lb ?? 5;
|
||||||
|
parts.push(
|
||||||
|
tol > 0 ? `${draft.weight_lb} lb (±${tol})` : `${draft.weight_lb} lb`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (draft.age_min != null || draft.age_max != null) {
|
||||||
|
const min = draft.age_min ?? 18;
|
||||||
|
const max = draft.age_max ?? 99;
|
||||||
|
parts.push(`age ${min}–${max}`);
|
||||||
|
}
|
||||||
|
if (draft.positions?.length) parts.push(draft.positions.join("/"));
|
||||||
|
if (draft.body_types?.length) parts.push(draft.body_types.join(", "));
|
||||||
|
if (draft.kinks?.length) parts.push(draft.kinks.slice(0, 3).join(", "));
|
||||||
|
if (draft.communities?.length) parts.push(draft.communities.join(", "));
|
||||||
|
return parts.length ? parts.join(" · ") : "No traits selected";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ruleHasCriteria(rule: HideFromRule): boolean {
|
||||||
|
return (
|
||||||
|
rule.height_in != null ||
|
||||||
|
rule.weight_lb != null ||
|
||||||
|
rule.age_min != null ||
|
||||||
|
rule.age_max != null ||
|
||||||
|
(rule.positions?.length ?? 0) > 0 ||
|
||||||
|
(rule.body_types?.length ?? 0) > 0 ||
|
||||||
|
(rule.kinks?.length ?? 0) > 0 ||
|
||||||
|
(rule.communities?.length ?? 0) > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Viewer matches rule when every specified trait on the rule matches the viewer profile. */
|
||||||
|
export function viewerMatchesHideRule(viewer: Profile, rule: HideFromRule): boolean {
|
||||||
|
if (!ruleHasCriteria(rule)) return false;
|
||||||
|
|
||||||
|
if (rule.height_in != null) {
|
||||||
|
if (viewer.height_in == null) return false;
|
||||||
|
const tol = rule.height_tolerance_in ?? 0;
|
||||||
|
if (Math.abs(viewer.height_in - rule.height_in) > tol) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.weight_lb != null) {
|
||||||
|
if (viewer.weight_lb == null) return false;
|
||||||
|
const tol = rule.weight_tolerance_lb ?? 5;
|
||||||
|
if (Math.abs(viewer.weight_lb - rule.weight_lb) > tol) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.age_min != null || rule.age_max != null) {
|
||||||
|
if (viewer.age == null) return false;
|
||||||
|
const min = rule.age_min ?? 18;
|
||||||
|
const max = rule.age_max ?? 99;
|
||||||
|
if (viewer.age < min || viewer.age > max) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.positions?.length) {
|
||||||
|
if (!viewer.position || !rule.positions.includes(viewer.position)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.body_types?.length) {
|
||||||
|
if (!viewer.body_type || !rule.body_types.includes(viewer.body_type)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.kinks?.length) {
|
||||||
|
if (!rule.kinks.some((k) => viewer.kinks.includes(k))) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rule.communities?.length) {
|
||||||
|
const community = viewer.community ?? (viewer.orientation === "gay" ? "gay-men" : null);
|
||||||
|
if (!community || !rule.communities.includes(community)) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function viewerHiddenByRules(
|
||||||
|
viewer: Profile,
|
||||||
|
rules: HideFromRule[]
|
||||||
|
): boolean {
|
||||||
|
return rules.some((r) => viewerMatchesHideRule(viewer, r));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadHideFromRules(userId?: string | null): HideFromRule[] {
|
||||||
|
if (typeof window === "undefined") return [];
|
||||||
|
try {
|
||||||
|
const key = userId ? LS_HIDE_RULES_USER(userId) : LS_HIDE_RULES;
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw) as HideFromRule[];
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveHideFromRules(rules: HideFromRule[], userId?: string | null): void {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const key = userId ? LS_HIDE_RULES_USER(userId) : LS_HIDE_RULES;
|
||||||
|
localStorage.setItem(key, JSON.stringify(rules));
|
||||||
|
window.dispatchEvent(new CustomEvent("eg-hide-rules", { detail: rules }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addHideFromRule(
|
||||||
|
draft: Omit<HideFromRule, "id" | "created_at" | "label"> & { label?: string },
|
||||||
|
userId?: string | null
|
||||||
|
): HideFromRule[] {
|
||||||
|
const label = draft.label ?? buildHideRuleLabel(draft);
|
||||||
|
const rule: HideFromRule = {
|
||||||
|
...draft,
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
label,
|
||||||
|
};
|
||||||
|
const next = [...loadHideFromRules(userId), rule];
|
||||||
|
saveHideFromRules(next, userId);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeHideFromRule(id: string, userId?: string | null): HideFromRule[] {
|
||||||
|
const next = loadHideFromRules(userId).filter((r) => r.id !== id);
|
||||||
|
saveHideFromRules(next, userId);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
121
src/lib/visibility.ts
Normal file
121
src/lib/visibility.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import type { Profile } from "@/types";
|
||||||
|
import type { BlockEntry } from "@/lib/block-list";
|
||||||
|
import { isBlocked, loadBlockList } from "@/lib/block-list";
|
||||||
|
import type { HideFromRule } from "@/lib/privacy-rules";
|
||||||
|
import { loadHideFromRules, viewerHiddenByRules } from "@/lib/privacy-rules";
|
||||||
|
|
||||||
|
export interface VisibilityContext {
|
||||||
|
/** The browsing user's profile (or guest stand-in). */
|
||||||
|
viewer: Profile | null;
|
||||||
|
viewerId: string | null;
|
||||||
|
/** My hide-from rules — people matching these cannot see me. */
|
||||||
|
myHideRules: HideFromRule[];
|
||||||
|
/** Profiles I have blocked. */
|
||||||
|
myBlockList: BlockEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Can `viewer` see `target` on map / chat / rails?
|
||||||
|
* - Block is mutual: either party blocked the other → hidden
|
||||||
|
* - Hide rules on target: if viewer matches target's hide rules, viewer cannot see target
|
||||||
|
*/
|
||||||
|
export function canViewerSeeTarget(
|
||||||
|
viewer: Profile | null,
|
||||||
|
viewerId: string | null,
|
||||||
|
target: Profile,
|
||||||
|
myHideRules: HideFromRule[],
|
||||||
|
myBlockList: BlockEntry[],
|
||||||
|
targetBlockList: BlockEntry[] = []
|
||||||
|
): boolean {
|
||||||
|
const effectiveViewerId = viewerId ?? viewer?.id ?? null;
|
||||||
|
|
||||||
|
if (effectiveViewerId === target.id) return true;
|
||||||
|
|
||||||
|
if (isBlocked(target.id, myBlockList)) return false;
|
||||||
|
if (effectiveViewerId && isBlocked(effectiveViewerId, targetBlockList)) return false;
|
||||||
|
|
||||||
|
if (viewer && viewerHiddenByRules(viewer, myHideRules)) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Filter targets visible to the current viewer (blocks + per-target hide rules). */
|
||||||
|
export function filterVisibleProfiles(
|
||||||
|
viewer: Profile | null,
|
||||||
|
viewerId: string | null,
|
||||||
|
targets: Profile[],
|
||||||
|
myBlockList: BlockEntry[]
|
||||||
|
): Profile[] {
|
||||||
|
return targets.filter((target) => {
|
||||||
|
const targetRules = loadHideFromRules(target.id);
|
||||||
|
const targetBlocks = loadBlockList(target.id);
|
||||||
|
return canViewerSeeTarget(
|
||||||
|
viewer,
|
||||||
|
viewerId,
|
||||||
|
target,
|
||||||
|
targetRules,
|
||||||
|
myBlockList,
|
||||||
|
targetBlocks
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a minimal guest viewer profile from local saved stats. */
|
||||||
|
export function guestViewerProfile(): Profile {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
let height_in: number | null = null;
|
||||||
|
let weight_lb: number | null = null;
|
||||||
|
let age: number | null = null;
|
||||||
|
let position: Profile["position"] = null;
|
||||||
|
let body_type: string | null = null;
|
||||||
|
let kinks: string[] = [];
|
||||||
|
let community: Profile["community"] = "gay-men";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem("eg_profile");
|
||||||
|
if (raw) {
|
||||||
|
const p = JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
if (typeof p.height_in === "number") height_in = p.height_in;
|
||||||
|
else if (typeof p.heightIn === "number") height_in = p.heightIn;
|
||||||
|
if (typeof p.weight_lb === "number") weight_lb = p.weight_lb;
|
||||||
|
else if (typeof p.weightLb === "number") weight_lb = p.weightLb;
|
||||||
|
if (typeof p.age === "number") age = p.age;
|
||||||
|
if (typeof p.body_type === "string") body_type = p.body_type;
|
||||||
|
else if (typeof p.bodyType === "string") body_type = p.bodyType;
|
||||||
|
if (p.position) position = p.position as Profile["position"];
|
||||||
|
if (typeof p.body_type === "string") body_type = p.body_type;
|
||||||
|
if (Array.isArray(p.kinks)) kinks = p.kinks as string[];
|
||||||
|
if (p.community) community = p.community as Profile["community"];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: "guest",
|
||||||
|
username: null,
|
||||||
|
display_name: null,
|
||||||
|
age,
|
||||||
|
position,
|
||||||
|
bio: null,
|
||||||
|
avatar_url: null,
|
||||||
|
status: "online",
|
||||||
|
is_anonymous: true,
|
||||||
|
kinks,
|
||||||
|
sti_status: null,
|
||||||
|
on_prep: false,
|
||||||
|
last_active: now,
|
||||||
|
lat: null,
|
||||||
|
lng: null,
|
||||||
|
city: null,
|
||||||
|
state: null,
|
||||||
|
id_verified: false,
|
||||||
|
id_verified_at: null,
|
||||||
|
vanilla_mode: false,
|
||||||
|
created_at: now,
|
||||||
|
height_in,
|
||||||
|
weight_lb,
|
||||||
|
body_type,
|
||||||
|
community,
|
||||||
|
};
|
||||||
|
}
|
||||||
48
supabase/migration_v5_privacy.sql
Normal file
48
supabase/migration_v5_privacy.sql
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
-- ExposedGays privacy: trait hide rules + block list (cloud sync)
|
||||||
|
|
||||||
|
ALTER TABLE profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS height_in integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS weight_lb integer,
|
||||||
|
ADD COLUMN IF NOT EXISTS body_type text;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS hide_from_rules (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
label text NOT NULL DEFAULT '',
|
||||||
|
height_in integer,
|
||||||
|
height_tolerance_in integer DEFAULT 1,
|
||||||
|
weight_lb integer,
|
||||||
|
weight_tolerance_lb integer DEFAULT 10,
|
||||||
|
age_min integer,
|
||||||
|
age_max integer,
|
||||||
|
positions text[] DEFAULT '{}',
|
||||||
|
body_types text[] DEFAULT '{}',
|
||||||
|
kinks text[] DEFAULT '{}',
|
||||||
|
communities text[] DEFAULT '{}',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_hide_from_rules_user ON hide_from_rules(user_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_blocks (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
blocker_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
blocked_id uuid NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
blocked_username text,
|
||||||
|
blocked_display_name text,
|
||||||
|
blocked_avatar_url text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (blocker_id, blocked_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_blocks_blocker ON user_blocks(blocker_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_blocks_blocked ON user_blocks(blocked_id);
|
||||||
|
|
||||||
|
ALTER TABLE hide_from_rules ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE user_blocks ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
CREATE POLICY hide_from_rules_own ON hide_from_rules
|
||||||
|
FOR ALL USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
CREATE POLICY user_blocks_own ON user_blocks
|
||||||
|
FOR ALL USING (auth.uid() = blocker_id);
|
||||||
Reference in New Issue
Block a user