Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 105 additions & 0 deletions src/components/post/poll-composer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"use client";

import { X, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { PollDuration } from "@/lib/types";

interface PollComposerProps {
options: string[];
duration: PollDuration;
onChange: (poll: { options: string[]; duration: PollDuration }) => void;
onRemove: () => void;
}

const DURATION_LABELS: Record<PollDuration, string> = {
"1d": "1 dia",
"3d": "3 dias",
"7d": "7 dias",
};

export function PollComposer({ options, duration, onChange, onRemove }: PollComposerProps) {
const updateOption = (index: number, value: string) => {
const updated = [...options];
updated[index] = value;
onChange({ options: updated, duration });
};

const addOption = () => {
if (options.length >= 4) return;
onChange({ options: [...options, ""], duration });
};

const removeOption = (index: number) => {
if (options.length <= 2) return;
onChange({ options: options.filter((_, i) => i !== index), duration });
};

return (
<div className="mt-3 rounded-xl border p-3 space-y-3">
<div className="space-y-2">
{options.map((option, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="text"
value={option}
onChange={(e) => updateOption(i, e.target.value)}
placeholder={`Opcion ${i + 1}`}
maxLength={80}
className="flex-1 rounded-lg border bg-transparent px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-xcion-primary"
/>
{options.length > 2 && (
<button
type="button"
onClick={() => removeOption(i)}
className="shrink-0 rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label={`Eliminar opcion ${i + 1}`}
>
<X className="h-4 w-4" />
</button>
)}
</div>
))}
</div>

{options.length < 4 && (
<button
type="button"
onClick={addOption}
className="flex items-center gap-1.5 text-sm text-xcion-primary hover:underline"
>
<Plus className="h-3.5 w-3.5" />
Agregar opcion
</button>
)}

<div className="flex items-center gap-2 border-t pt-3">
<span className="text-sm text-muted-foreground">Duracion:</span>
<div className="flex gap-1">
{(Object.keys(DURATION_LABELS) as PollDuration[]).map((d) => (
<button
key={d}
type="button"
onClick={() => onChange({ options, duration: d })}
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
duration === d
? "bg-xcion-primary text-white"
: "bg-accent text-muted-foreground hover:text-foreground"
}`}
>
{DURATION_LABELS[d]}
</button>
))}
</div>
<Button
variant="ghost"
size="sm"
onClick={onRemove}
className="ml-auto text-destructive hover:text-destructive"
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
Eliminar encuesta
</Button>
</div>
</div>
);
}
79 changes: 79 additions & 0 deletions src/components/post/poll-display.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"use client";

import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
import type { PollWithOptions } from "@/lib/types";

interface PollDisplayProps {
poll: PollWithOptions;
onVote: (optionId: string) => void;
isVoting: boolean;
}

function formatTimeRemaining(expiresAt: string): string {
const diff = new Date(expiresAt).getTime() - Date.now();
if (diff <= 0) return "Finalizada";

const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));

if (hours >= 24) {
const days = Math.floor(hours / 24);
return `${days}d restantes`;
}
if (hours > 0) return `${hours}h ${minutes}m restantes`;
return `${minutes}m restantes`;
}

export function PollDisplay({ poll, onVote, isVoting }: PollDisplayProps) {
const hasVoted = poll.user_vote_option_id !== null;
const showResults = hasVoted || poll.is_expired;

return (
<div className="mt-3 space-y-2" onClick={(e) => e.stopPropagation()}>
{poll.options.map((option) => {
const percentage =
poll.total_votes > 0 ? Math.round((option.vote_count / poll.total_votes) * 100) : 0;
const isUserChoice = option.id === poll.user_vote_option_id;

if (showResults) {
return (
<div key={option.id} className="relative overflow-hidden rounded-lg border py-2 px-3">
<div
className={cn(
"absolute inset-y-0 left-0 transition-all",
isUserChoice ? "bg-xcion-primary/20" : "bg-accent"
)}
style={{ width: `${percentage}%` }}
/>
<div className="relative flex items-center justify-between text-sm">
<span className="flex items-center gap-1.5 font-medium">
{isUserChoice && <Check className="h-3.5 w-3.5 text-xcion-primary" />}
{option.label}
</span>
<span className="font-medium text-muted-foreground">{percentage}%</span>
</div>
</div>
);
}

return (
<button
key={option.id}
type="button"
onClick={() => onVote(option.id)}
disabled={isVoting}
className="w-full rounded-lg border py-2 px-3 text-left text-sm font-medium transition-colors hover:border-xcion-primary hover:text-xcion-primary disabled:opacity-50"
>
{option.label}
</button>
);
})}

<p className="text-xs text-muted-foreground">
{poll.total_votes} {poll.total_votes === 1 ? "voto" : "votos"} ·{" "}
{formatTimeRemaining(poll.expires_at)}
</p>
</div>
);
}
11 changes: 11 additions & 0 deletions src/components/post/post-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ import { Textarea } from "@/components/ui/textarea";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { PostActions } from "./post-actions";
import { PostContent } from "./post-content";
import { PollDisplay } from "./poll-display";
import { useAuth } from "@/hooks/use-auth";
import { useDeletePost, useUpdatePost } from "@/hooks/use-posts";
import { useVotePoll } from "@/hooks/use-polls";
import { formatRelativeTime, getInitials, cn } from "@/lib/utils";
import { toast } from "sonner";
import type { PostWithCounts } from "@/lib/types";
Expand All @@ -48,6 +50,7 @@ export function PostCard({
const { user, isAdmin } = useAuth();
const deletePost = useDeletePost();
const updatePost = useUpdatePost();
const votePoll = useVotePoll();
const [editOpen, setEditOpen] = useState(false);
const [editContent, setEditContent] = useState(post.content);
const [lightboxOpen, setLightboxOpen] = useState(false);
Expand Down Expand Up @@ -191,6 +194,14 @@ export function PostCard({

<PostContent text={post.content} />

{post.poll && (
<PollDisplay
poll={post.poll}
onVote={(optionId) => votePoll.mutate({ pollId: post.poll!.id, optionId })}
isVoting={votePoll.isPending}
/>
)}

{post.media_urls.length > 0 && (
<div
className={cn(
Expand Down
49 changes: 43 additions & 6 deletions src/components/post/post-composer.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
"use client";

import { useState, useRef, useEffect, useCallback } from "react";
import { ImagePlus, X, Loader2, Bot } from "lucide-react";
import { ImagePlus, X, Loader2, Bot, BarChart3 } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { useAuth } from "@/hooks/use-auth";
import { useCreatePost } from "@/hooks/use-posts";
import { useMentionSuggestions } from "@/hooks/use-search";
import { getInitials } from "@/lib/utils";
import { getInitials, cn } from "@/lib/utils";
import { PollComposer } from "@/components/post/poll-composer";
import { createClient } from "@/lib/supabase/client";
import { toast } from "sonner";
import imageCompression from "browser-image-compression";
import Image from "next/image";
import type { PollDuration } from "@/lib/types";

function getMentionQuery(text: string, cursorPos: number): string | null {
const before = text.slice(0, cursorPos);
Expand Down Expand Up @@ -56,6 +58,9 @@ export function PostComposer({
const [mediaFiles, setMediaFiles] = useState<File[]>([]);
const [mediaPreviews, setMediaPreviews] = useState<string[]>([]);
const [uploading, setUploading] = useState(false);
const [pollData, setPollData] = useState<{ options: string[]; duration: PollDuration } | null>(
null
);
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
const [mentionIndex, setMentionIndex] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
Expand Down Expand Up @@ -155,8 +160,10 @@ export function PostComposer({
setMediaPreviews((prev) => prev.filter((_, i) => i !== index));
};

const pollIsValid = pollData !== null && pollData.options.filter((o) => o.trim()).length >= 2;

const handleSubmit = async () => {
if (!content.trim() && mediaFiles.length === 0) return;
if (!content.trim() && mediaFiles.length === 0 && !pollIsValid) return;

try {
let mediaUrls: string[] = [];
Expand All @@ -181,13 +188,18 @@ export function PostComposer({
setUploading(false);
}

const pollPayload = pollIsValid
? { options: pollData.options.filter((o) => o.trim()), duration: pollData.duration }
: undefined;

createPost.mutate(
{ content: content.trim(), mediaUrls, parentId },
{ content: content.trim(), mediaUrls, parentId, poll: pollPayload },
{
onSuccess: () => {
setContent("");
setMediaFiles([]);
setMediaPreviews([]);
setPollData(null);
toast.success(parentId ? "Respuesta publicada" : "Publicación creada");
onSuccess?.();
},
Expand Down Expand Up @@ -284,6 +296,15 @@ export function PostComposer({
</div>
)}

{pollData && (
<PollComposer
options={pollData.options}
duration={pollData.duration}
onChange={setPollData}
onRemove={() => setPollData(null)}
/>
)}

<div className="mt-3 flex items-center justify-between border-t pt-3">
<div className="flex items-center gap-1">
<input
Expand All @@ -300,18 +321,34 @@ export function PostComposer({
size="icon"
className="h-9 w-9 text-xcion-primary"
onClick={() => fileInputRef.current?.click()}
disabled={mediaFiles.length >= 4}
disabled={mediaFiles.length >= 4 || pollData !== null}
>
<ImagePlus className="h-5 w-5" />
</Button>
<Button
variant="ghost"
size="icon"
className={cn(
"h-9 w-9",
pollData !== null ? "text-xcion-primary bg-xcion-primary/10" : "text-xcion-primary"
)}
onClick={() =>
setPollData(pollData ? null : { options: ["", ""], duration: "1d" as PollDuration })
}
disabled={mediaFiles.length > 0}
>
<BarChart3 className="h-5 w-5" />
</Button>
</div>

<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">{content.length}/280</span>
<Button
onClick={handleSubmit}
disabled={
(!content.trim() && mediaFiles.length === 0) || createPost.isPending || uploading
(!content.trim() && mediaFiles.length === 0 && !pollIsValid) ||
createPost.isPending ||
uploading
}
className="rounded-full bg-xcion-primary px-4 text-white hover:bg-xcion-primary-hover"
>
Expand Down
6 changes: 3 additions & 3 deletions src/hooks/use-app-badge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,15 @@ import { useEffect } from "react";
import { useUnreadCount } from "@/hooks/use-notifications";

export function useAppBadge() {
const { data: unreadCount } = useUnreadCount();
const { data: unreadCount, isLoading } = useUnreadCount();

useEffect(() => {
if (!("setAppBadge" in navigator)) return;
if (!("setAppBadge" in navigator) || isLoading) return;

if (unreadCount && unreadCount > 0) {
navigator.setAppBadge(unreadCount).catch(() => {});
} else {
navigator.clearAppBadge().catch(() => {});
}
}, [unreadCount]);
}, [unreadCount, isLoading]);
}
1 change: 1 addition & 0 deletions src/hooks/use-notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export function useUnreadCount() {
},
enabled: !authLoading && !!effectiveProfileId,
staleTime: 10_000,
refetchInterval: 30_000,
});
}

Expand Down
Loading
Loading