feat: implement optional cell icon with URL validation

- Add urlLoads utility for image validation with 5s timeout
    - Update CreateCellDialog form schema to make icon optional
    - Add client-side URL validation with error toast
    - Update CypherImage to handle missing/empty icon sources
    - Update all backend actions and transformers for optional icon
    - Maintain backward compatibility with existing cell messages

Signed-off-by: Ashis Kumar Naik <ashishami2002@gmail.com>
This commit is contained in:
Ashis Kumar Naik 2025-06-28 07:13:32 +05:30
parent c34fc7118c
commit 35bc6ac15f
8 changed files with 58 additions and 16 deletions

View File

@ -23,16 +23,24 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useToast } from "@/hooks/use-toast";
import { urlLoads } from "@/lib/utils/urlLoads";
const formSchema = z.object({
title: z.string().min(3, "Title must be at least 3 characters").max(50, "Title must be less than 50 characters"),
description: z.string().min(10, "Description must be at least 10 characters").max(200, "Description must be less than 200 characters"),
icon: z.string().url("Please enter a valid URL for the icon"),
icon: z
.string()
.optional()
.refine((val) => !val || val.length === 0 || URL.canParse(val), {
message: "Must be a valid URL"
}),
});
export function CreateCellDialog() {
const { createCell, isPostingCell } = useForum();
const { isAuthenticated } = useAuth();
const { toast } = useToast();
const [open, setOpen] = React.useState(false);
const form = useForm<z.infer<typeof formSchema>>({
@ -40,12 +48,25 @@ export function CreateCellDialog() {
defaultValues: {
title: "",
description: "",
icon: "",
icon: undefined,
},
});
const onSubmit = async (values: z.infer<typeof formSchema>) => {
const cell = await createCell(values.title, values.description, values.icon);
// Validate icon URL if provided
if (values.icon && values.icon.trim()) {
const ok = await urlLoads(values.icon, 5000);
if (!ok) {
toast({
title: "Icon URL Error",
description: "Icon URL could not be loaded. Please check the URL and try again.",
variant: "destructive",
});
return;
}
}
const cell = await createCell(values.title, values.description, values.icon || undefined);
if (cell) {
setOpen(false);
form.reset();
@ -100,12 +121,13 @@ export function CreateCellDialog() {
name="icon"
render={({ field }) => (
<FormItem>
<FormLabel>Icon URL</FormLabel>
<FormLabel>Icon URL (optional)</FormLabel>
<FormControl>
<Input
placeholder="Enter icon URL"
placeholder="Enter icon URL (optional)"
type="url"
{...field}
value={field.value || ""}
disabled={isPostingCell}
/>
</FormControl>

View File

@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { cn } from '@/lib/utils';
type CypherImageProps = {
src: string;
src?: string;
alt: string;
className?: string;
fallbackClassName?: string;
@ -25,14 +25,14 @@ export function CypherImage({
// Generate a seed based on the alt text or src to create consistent fallbacks for the same resource
const seed = generateUniqueFallback ?
(alt || src).split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) : 0;
(alt || src || 'fallback').split('').reduce((acc, char) => acc + char.charCodeAt(0), 0) : 0;
// Handle image load error
const handleError = () => {
setImageError(true);
};
if (imageError) {
if (imageError || !src || src.trim() === '') {
// Generate some values based on the seed for variety
const hue = (seed % 60) + 140; // Cyan-ish colors (140-200)
const gridSize = (seed % 8) + 8; // 8-16px

View File

@ -40,7 +40,7 @@ interface ForumContextType {
createComment: (postId: string, content: string) => Promise<Comment | null>;
votePost: (postId: string, isUpvote: boolean) => Promise<boolean>;
voteComment: (commentId: string, isUpvote: boolean) => Promise<boolean>;
createCell: (name: string, description: string, icon: string) => Promise<Cell | null>;
createCell: (name: string, description: string, icon?: string) => Promise<Cell | null>;
refreshData: () => Promise<void>;
moderatePost: (
cellId: string,
@ -223,7 +223,7 @@ export function ForumProvider({ children }: { children: React.ReactNode }) {
return result;
};
const handleCreateCell = async (name: string, description: string, icon: string): Promise<Cell | null> => {
const handleCreateCell = async (name: string, description: string, icon?: string): Promise<Cell | null> => {
setIsPostingCell(true);
const result = await createCell(
name,

View File

@ -214,7 +214,7 @@ export const createComment = async (
export const createCell = async (
name: string,
description: string,
icon: string,
icon: string | undefined,
currentUser: User | null,
isAuthenticated: boolean,
toast: ToastFunction,
@ -243,7 +243,7 @@ export const createCell = async (
id: cellId,
name,
description,
icon,
...(icon && { icon }),
timestamp: Date.now(),
author: currentUser.address
};

View File

@ -17,7 +17,7 @@ export const transformCell = (
id: cellMessage.id,
name: cellMessage.name,
description: cellMessage.description,
icon: cellMessage.icon,
icon: cellMessage.icon || '',
signature: cellMessage.signature,
browserPubKey: cellMessage.browserPubKey
};

View File

@ -21,6 +21,6 @@ export interface ForumContextType {
createComment: (postId: string, content: string) => Promise<Comment | null>;
votePost: (postId: string, isUpvote: boolean) => Promise<boolean>;
voteComment: (commentId: string, isUpvote: boolean) => Promise<boolean>;
createCell: (name: string, description: string, icon: string) => Promise<Cell | null>;
createCell: (name: string, description: string, icon?: string) => Promise<Cell | null>;
refreshData: () => Promise<void>;
}

20
src/lib/utils/urlLoads.ts Normal file
View File

@ -0,0 +1,20 @@
/**
* Utility to check if a URL loads successfully within a timeout
*/
export async function urlLoads(url: string, timeoutMs: number = 5000): Promise<boolean> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(url, {
method: 'HEAD',
signal: controller.signal,
cache: 'no-cache'
});
clearTimeout(timeoutId);
return response.ok;
} catch (error) {
return false;
}
}

View File

@ -11,7 +11,7 @@ export function cellToMessage(cell: Cell, sender: string): CellMessage {
id: cell.id,
name: cell.name,
description: cell.description,
icon: cell.icon
...(cell.icon && { icon: cell.icon })
};
}
@ -20,7 +20,7 @@ export function messageToCell(message: CellMessage): Cell {
id: message.id,
name: message.name,
description: message.description,
icon: message.icon
icon: message.icon || ''
};
}