NIP-51: Interest Set (#39)

* feat: implement Interest Sets management and display in settings and homepage

* Update src/pages/HomePage.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update src/hooks/useInterestSets.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Fix whitespace preservation in Interest Set identifier publishing (#40)

* Initial plan

* fix: trim identifier before publishing interest set

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>

* Update src/components/InterestSetsManager.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
mroxso
2025-11-22 21:51:00 +01:00
committed by GitHub
parent 512587b18c
commit 325210886b
7 changed files with 621 additions and 33 deletions

View File

@@ -0,0 +1,358 @@
import { useState } from 'react';
import { Plus, Trash2, Edit2, Hash, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { useInterestSets, type InterestSet } from '@/hooks/useInterestSets';
import { usePublishInterestSet } from '@/hooks/usePublishInterestSet';
import { useDeleteInterestSet } from '@/hooks/useDeleteInterestSet';
import { useCurrentUser } from '@/hooks/useCurrentUser';
interface InterestSetFormData {
identifier: string;
title: string;
image: string;
description: string;
hashtags: string[];
}
const defaultFormData: InterestSetFormData = {
identifier: '',
title: '',
image: '',
description: '',
hashtags: [],
};
function InterestSetDialog({
interestSet,
trigger,
onSuccess
}: {
interestSet?: InterestSet;
trigger: React.ReactNode;
onSuccess?: () => void;
}) {
const [open, setOpen] = useState(false);
const [formData, setFormData] = useState<InterestSetFormData>(
interestSet
? {
identifier: interestSet.identifier,
title: interestSet.title || '',
image: interestSet.image || '',
description: interestSet.description || '',
hashtags: interestSet.hashtags,
}
: defaultFormData
);
const [hashtagInput, setHashtagInput] = useState('');
const { mutate: publishInterestSet, isPending } = usePublishInterestSet();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.identifier.trim()) {
return;
}
publishInterestSet(
{
identifier: formData.identifier.trim(),
title: formData.title.trim() || undefined,
image: formData.image.trim() || undefined,
description: formData.description.trim() || undefined,
hashtags: formData.hashtags,
},
{
onSuccess: () => {
setOpen(false);
setFormData(defaultFormData);
setHashtagInput('');
onSuccess?.();
},
}
);
};
const handleAddHashtag = () => {
const tag = hashtagInput.trim().toLowerCase().replace(/^#/, '');
if (tag && !formData.hashtags.includes(tag)) {
setFormData({ ...formData, hashtags: [...formData.hashtags, tag] });
setHashtagInput('');
}
};
const handleRemoveHashtag = (hashtag: string) => {
setFormData({
...formData,
hashtags: formData.hashtags.filter((h) => h !== hashtag),
});
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
handleAddHashtag();
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{interestSet ? 'Edit Interest Set' : 'Create Interest Set'}
</DialogTitle>
<DialogDescription>
Group hashtags together to organize your interests. These will appear as sections on your homepage.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="identifier">
Identifier <span className="text-destructive">*</span>
</Label>
<Input
id="identifier"
value={formData.identifier}
onChange={(e) =>
setFormData({ ...formData, identifier: e.target.value })
}
placeholder="e.g., tech, entertainment"
required
disabled={!!interestSet}
/>
<p className="text-xs text-muted-foreground">
A unique identifier for this set. Cannot be changed after creation.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="e.g., Technology & Innovation"
/>
</div>
<div className="space-y-2">
<Label htmlFor="image">Image URL</Label>
<Input
id="image"
value={formData.image}
onChange={(e) => setFormData({ ...formData, image: e.target.value })}
placeholder="https://example.com/image.jpg"
type="url"
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder="Describe what this interest set is about..."
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="hashtags">
Hashtags <span className="text-destructive">*</span>
</Label>
<div className="flex gap-2">
<Input
id="hashtags"
value={hashtagInput}
onChange={(e) => setHashtagInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a hashtag and press Enter"
/>
<Button type="button" onClick={handleAddHashtag} variant="outline">
<Plus className="h-4 w-4" />
</Button>
</div>
{formData.hashtags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{formData.hashtags.map((hashtag) => (
<Badge key={hashtag} variant="secondary" className="gap-1">
<Hash className="h-3 w-3" />
{hashtag}
<button
type="button"
onClick={() => handleRemoveHashtag(hashtag)}
className="ml-1 hover:text-destructive"
>
<X className="h-3 w-3" />
</button>
</Badge>
))}
</div>
)}
<p className="text-xs text-muted-foreground">
At least one hashtag is required.
</p>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isPending}
>
Cancel
</Button>
<Button type="submit" disabled={isPending || formData.hashtags.length === 0}>
{isPending ? 'Saving...' : interestSet ? 'Update' : 'Create'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export function InterestSetsManager() {
const { user } = useCurrentUser();
const { data: interestSets, isLoading } = useInterestSets();
const { mutate: deleteInterestSet } = useDeleteInterestSet();
if (!user) {
return (
<div className="text-center py-8 text-muted-foreground">
You must be logged in to manage interest sets.
</div>
);
}
if (isLoading) {
return (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-full" />
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-6 w-20" />
<Skeleton className="h-6 w-20" />
</div>
</CardContent>
</Card>
))}
</div>
);
}
const handleDelete = (interestSet: InterestSet) => {
if (confirm(`Are you sure you want to delete "${interestSet.title || interestSet.identifier}"?`)) {
deleteInterestSet(interestSet.event);
}
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<div>
<p className="text-sm text-muted-foreground">
Create custom interest sets to organize your homepage feed. Each set groups related hashtags together.
</p>
</div>
<InterestSetDialog
trigger={
<Button>
<Plus className="h-4 w-4 mr-2" />
New Interest Set
</Button>
}
/>
</div>
{!interestSets || interestSets.length === 0 ? (
<Card className="border-dashed">
<CardContent className="py-12 text-center">
<Hash className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground mb-4">
No interest sets yet. Create your first one to customize your homepage.
</p>
<InterestSetDialog
trigger={
<Button variant="outline">
<Plus className="h-4 w-4 mr-2" />
Create Your First Interest Set
</Button>
}
/>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{interestSets.map((interestSet) => (
<Card key={interestSet.id}>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="flex items-center gap-2">
{interestSet.title || interestSet.identifier}
<Badge variant="outline" className="font-normal">
{interestSet.identifier}
</Badge>
</CardTitle>
{interestSet.description && (
<CardDescription className="mt-1">
{interestSet.description}
</CardDescription>
)}
</div>
<div className="flex gap-2">
<InterestSetDialog
interestSet={interestSet}
trigger={
<Button variant="ghost" size="icon">
<Edit2 className="h-4 w-4" />
</Button>
}
/>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(interestSet)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{interestSet.hashtags.map((hashtag) => (
<Badge key={hashtag} variant="secondary">
<Hash className="h-3 w-3 mr-1" />
{hashtag}
</Badge>
))}
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -9,11 +9,12 @@ import { ArticlePreview } from '@/components/ArticlePreview';
interface LatestInHashtagProps {
hashtag: string;
icon?: React.ReactNode;
title?: string;
}
const INITIAL_POSTS_COUNT = 3;
export function LatestInHashtag({ hashtag, icon }: LatestInHashtagProps) {
export function LatestInHashtag({ hashtag, icon, title }: LatestInHashtagProps) {
const navigate = useNavigate();
const { data: posts, isLoading } = useBlogPostsByHashtag(hashtag, 4);
@@ -58,7 +59,7 @@ export function LatestInHashtag({ hashtag, icon }: LatestInHashtagProps) {
{icon || <Hash className="h-8 w-8 text-primary" />}
<div className="flex-1">
<h2 className="text-3xl font-bold tracking-tight">
Latest in #{hashtag}
{title || `Latest in #${hashtag}`}
</h2>
{/* <p className="text-sm text-muted-foreground mt-1">
{posts.length} {posts.length === 1 ? 'article' : 'articles'} in this category