init
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function IsolatedContent({ html }: { html: string }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
let shadow = containerRef.current.shadowRoot;
|
||||
if (!shadow) {
|
||||
shadow = containerRef.current.attachShadow({ mode: "open" });
|
||||
}
|
||||
|
||||
const styleTag = `
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
color: inherit;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.inner-content {
|
||||
font-size: 14.5px;
|
||||
color: currentColor;
|
||||
}
|
||||
p { margin-bottom: 1rem; }
|
||||
h1, h2, h3 { margin-top: 1.5rem; margin-bottom: 0.5rem; font-weight: 700; line-height: 1.2; }
|
||||
ul, ol { padding-left: 1.5rem; margin-bottom: 1rem; }
|
||||
li { margin-bottom: 0.25rem; }
|
||||
img { max-width: 100%; height: auto; border-radius: 8px; }
|
||||
a { color: #3b82f6; text-decoration: underline; }
|
||||
blockquote { border-left: 4px solid #e5e7eb; padding-left: 1rem; font-style: italic; color: #6b7280; }
|
||||
</style>
|
||||
`;
|
||||
|
||||
|
||||
shadow.innerHTML = `${styleTag}<div class="inner-content">${html || "<i>Không có nội dung.</i>"}</div>`;
|
||||
}
|
||||
}, [html]);
|
||||
|
||||
return <div ref={containerRef} />;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
interface AlertProps {
|
||||
variant: "success" | "error" | "warning" | "info"; // Alert type
|
||||
title: string; // Title of the alert
|
||||
message: string; // Message of the alert
|
||||
showLink?: boolean; // Whether to show the "Learn More" link
|
||||
linkHref?: string; // Link URL
|
||||
linkText?: string; // Link text
|
||||
}
|
||||
|
||||
const Alert: React.FC<AlertProps> = ({
|
||||
variant,
|
||||
title,
|
||||
message,
|
||||
showLink = false,
|
||||
linkHref = "#",
|
||||
linkText = "Learn more",
|
||||
}) => {
|
||||
// Tailwind classes for each variant
|
||||
const variantClasses = {
|
||||
success: {
|
||||
container:
|
||||
"border-success-500 bg-success-50 dark:border-success-500/30 dark:bg-success-500/15",
|
||||
icon: "text-success-500",
|
||||
},
|
||||
error: {
|
||||
container:
|
||||
"border-error-500 bg-error-50 dark:border-error-500/30 dark:bg-error-500/15",
|
||||
icon: "text-error-500",
|
||||
},
|
||||
warning: {
|
||||
container:
|
||||
"border-warning-500 bg-warning-50 dark:border-warning-500/30 dark:bg-warning-500/15",
|
||||
icon: "text-warning-500",
|
||||
},
|
||||
info: {
|
||||
container:
|
||||
"border-blue-light-500 bg-blue-light-50 dark:border-blue-light-500/30 dark:bg-blue-light-500/15",
|
||||
icon: "text-blue-light-500",
|
||||
},
|
||||
};
|
||||
|
||||
// Icon for each variant
|
||||
const icons = {
|
||||
success: (
|
||||
<svg
|
||||
className="fill-current"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M3.70186 12.0001C3.70186 7.41711 7.41711 3.70186 12.0001 3.70186C16.5831 3.70186 20.2984 7.41711 20.2984 12.0001C20.2984 16.5831 16.5831 20.2984 12.0001 20.2984C7.41711 20.2984 3.70186 16.5831 3.70186 12.0001ZM12.0001 1.90186C6.423 1.90186 1.90186 6.423 1.90186 12.0001C1.90186 17.5772 6.423 22.0984 12.0001 22.0984C17.5772 22.0984 22.0984 17.5772 22.0984 12.0001C22.0984 6.423 17.5772 1.90186 12.0001 1.90186ZM15.6197 10.7395C15.9712 10.388 15.9712 9.81819 15.6197 9.46672C15.2683 9.11525 14.6984 9.11525 14.347 9.46672L11.1894 12.6243L9.6533 11.0883C9.30183 10.7368 8.73198 10.7368 8.38051 11.0883C8.02904 11.4397 8.02904 12.0096 8.38051 12.3611L10.553 14.5335C10.7217 14.7023 10.9507 14.7971 11.1894 14.7971C11.428 14.7971 11.657 14.7023 11.8257 14.5335L15.6197 10.7395Z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg
|
||||
className="fill-current"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M20.3499 12.0004C20.3499 16.612 16.6115 20.3504 11.9999 20.3504C7.38832 20.3504 3.6499 16.612 3.6499 12.0004C3.6499 7.38881 7.38833 3.65039 11.9999 3.65039C16.6115 3.65039 20.3499 7.38881 20.3499 12.0004ZM11.9999 22.1504C17.6056 22.1504 22.1499 17.6061 22.1499 12.0004C22.1499 6.3947 17.6056 1.85039 11.9999 1.85039C6.39421 1.85039 1.8499 6.3947 1.8499 12.0004C1.8499 17.6061 6.39421 22.1504 11.9999 22.1504ZM13.0008 16.4753C13.0008 15.923 12.5531 15.4753 12.0008 15.4753L11.9998 15.4753C11.4475 15.4753 10.9998 15.923 10.9998 16.4753C10.9998 17.0276 11.4475 17.4753 11.9998 17.4753L12.0008 17.4753C12.5531 17.4753 13.0008 17.0276 13.0008 16.4753ZM11.9998 6.62898C12.414 6.62898 12.7498 6.96476 12.7498 7.37898L12.7498 13.0555C12.7498 13.4697 12.414 13.8055 11.9998 13.8055C11.5856 13.8055 11.2498 13.4697 11.2498 13.0555L11.2498 7.37898C11.2498 6.96476 11.5856 6.62898 11.9998 6.62898Z"
|
||||
fill="#F04438"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg
|
||||
className="fill-current"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M3.6501 12.0001C3.6501 7.38852 7.38852 3.6501 12.0001 3.6501C16.6117 3.6501 20.3501 7.38852 20.3501 12.0001C20.3501 16.6117 16.6117 20.3501 12.0001 20.3501C7.38852 20.3501 3.6501 16.6117 3.6501 12.0001ZM12.0001 1.8501C6.39441 1.8501 1.8501 6.39441 1.8501 12.0001C1.8501 17.6058 6.39441 22.1501 12.0001 22.1501C17.6058 22.1501 22.1501 17.6058 22.1501 12.0001C22.1501 6.39441 17.6058 1.8501 12.0001 1.8501ZM10.9992 7.52517C10.9992 8.07746 11.4469 8.52517 11.9992 8.52517H12.0002C12.5525 8.52517 13.0002 8.07746 13.0002 7.52517C13.0002 6.97289 12.5525 6.52517 12.0002 6.52517H11.9992C11.4469 6.52517 10.9992 6.97289 10.9992 7.52517ZM12.0002 17.3715C11.586 17.3715 11.2502 17.0357 11.2502 16.6215V10.945C11.2502 10.5308 11.586 10.195 12.0002 10.195C12.4144 10.195 12.7502 10.5308 12.7502 10.945V16.6215C12.7502 17.0357 12.4144 17.3715 12.0002 17.3715Z"
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg
|
||||
className="fill-current"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M3.6501 11.9996C3.6501 7.38803 7.38852 3.64961 12.0001 3.64961C16.6117 3.64961 20.3501 7.38803 20.3501 11.9996C20.3501 16.6112 16.6117 20.3496 12.0001 20.3496C7.38852 20.3496 3.6501 16.6112 3.6501 11.9996ZM12.0001 1.84961C6.39441 1.84961 1.8501 6.39392 1.8501 11.9996C1.8501 17.6053 6.39441 22.1496 12.0001 22.1496C17.6058 22.1496 22.1501 17.6053 22.1501 11.9996C22.1501 6.39392 17.6058 1.84961 12.0001 1.84961ZM10.9992 7.52468C10.9992 8.07697 11.4469 8.52468 11.9992 8.52468H12.0002C12.5525 8.52468 13.0002 8.07697 13.0002 7.52468C13.0002 6.9724 12.5525 6.52468 12.0002 6.52468H11.9992C11.4469 6.52468 10.9992 6.9724 10.9992 7.52468ZM12.0002 17.371C11.586 17.371 11.2502 17.0352 11.2502 16.621V10.9445C11.2502 10.5303 11.586 10.1945 12.0002 10.1945C12.4144 10.1945 12.7502 10.5303 12.7502 10.9445V16.621C12.7502 17.0352 12.4144 17.371 12.0002 17.371Z"
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border p-4 ${variantClasses[variant].container}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`-mt-0.5 ${variantClasses[variant].icon}`}>
|
||||
{icons[variant]}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="mb-1 text-sm font-semibold text-gray-800 dark:text-white/90">
|
||||
{title}
|
||||
</h4>
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">{message}</p>
|
||||
|
||||
{showLink && (
|
||||
<Link
|
||||
href={linkHref}
|
||||
className="inline-block mt-3 text-sm font-medium text-gray-500 underline dark:text-gray-400"
|
||||
>
|
||||
{linkText}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Alert;
|
||||
@@ -0,0 +1,65 @@
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
|
||||
interface AvatarProps {
|
||||
src: string; // URL of the avatar image
|
||||
alt?: string; // Alt text for the avatar
|
||||
size?: "xsmall" | "small" | "medium" | "large" | "xlarge" | "xxlarge"; // Avatar size
|
||||
status?: "online" | "offline" | "busy" | "none"; // Status indicator
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
xsmall: "h-6 w-6 max-w-6",
|
||||
small: "h-8 w-8 max-w-8",
|
||||
medium: "h-10 w-10 max-w-10",
|
||||
large: "h-12 w-12 max-w-12",
|
||||
xlarge: "h-14 w-14 max-w-14",
|
||||
xxlarge: "h-16 w-16 max-w-16",
|
||||
};
|
||||
|
||||
const statusSizeClasses = {
|
||||
xsmall: "h-1.5 w-1.5 max-w-1.5",
|
||||
small: "h-2 w-2 max-w-2",
|
||||
medium: "h-2.5 w-2.5 max-w-2.5",
|
||||
large: "h-3 w-3 max-w-3",
|
||||
xlarge: "h-3.5 w-3.5 max-w-3.5",
|
||||
xxlarge: "h-4 w-4 max-w-4",
|
||||
};
|
||||
|
||||
const statusColorClasses = {
|
||||
online: "bg-success-500",
|
||||
offline: "bg-error-400",
|
||||
busy: "bg-warning-500",
|
||||
};
|
||||
|
||||
const Avatar: React.FC<AvatarProps> = ({
|
||||
src,
|
||||
alt = "User Avatar",
|
||||
size = "medium",
|
||||
status = "none",
|
||||
}) => {
|
||||
return (
|
||||
<div className={`relative rounded-full ${sizeClasses[size]}`}>
|
||||
{/* Avatar Image */}
|
||||
<Image
|
||||
width="0"
|
||||
height="0"
|
||||
sizes="100vw"
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="object-cover w-full rounded-full"
|
||||
/>
|
||||
|
||||
{/* Status Indicator */}
|
||||
{status !== "none" && (
|
||||
<span
|
||||
className={`absolute bottom-0 right-0 rounded-full border-[1.5px] border-white dark:border-gray-900 ${
|
||||
statusSizeClasses[size]
|
||||
} ${statusColorClasses[status] || ""}`}
|
||||
></span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Avatar;
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
|
||||
interface AvatarTextProps {
|
||||
name: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const AvatarText: React.FC<AvatarTextProps> = ({ name, className = "" }) => {
|
||||
// Generate initials from name
|
||||
const initials = name
|
||||
.split(" ")
|
||||
.map((word) => word[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
|
||||
// Generate a consistent pastel color based on the name
|
||||
const getColorClass = (name: string) => {
|
||||
const colors = [
|
||||
"bg-brand-100 text-brand-600",
|
||||
"bg-pink-100 text-pink-600",
|
||||
"bg-cyan-100 text-cyan-600",
|
||||
"bg-orange-100 text-orange-600",
|
||||
"bg-green-100 text-green-600",
|
||||
"bg-purple-100 text-purple-600",
|
||||
"bg-yellow-100 text-yellow-600",
|
||||
"bg-error-100 text-error-600",
|
||||
];
|
||||
|
||||
const index = name
|
||||
.split("")
|
||||
.reduce((acc, char) => acc + char.charCodeAt(0), 0);
|
||||
return colors[index % colors.length];
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex h-10 w-10 ${className} items-center justify-center rounded-full ${getColorClass(
|
||||
name
|
||||
)}`}
|
||||
>
|
||||
<span className="text-sm font-medium">{initials}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvatarText;
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from "react";
|
||||
|
||||
type BadgeVariant = "light" | "solid";
|
||||
type BadgeSize = "sm" | "md";
|
||||
type BadgeColor =
|
||||
| "primary"
|
||||
| "success"
|
||||
| "error"
|
||||
| "warning"
|
||||
| "info"
|
||||
| "light"
|
||||
| "dark";
|
||||
|
||||
interface BadgeProps {
|
||||
variant?: BadgeVariant; // Light or solid variant
|
||||
size?: BadgeSize; // Badge size
|
||||
color?: BadgeColor; // Badge color
|
||||
startIcon?: React.ReactNode; // Icon at the start
|
||||
endIcon?: React.ReactNode; // Icon at the end
|
||||
children: React.ReactNode; // Badge content
|
||||
}
|
||||
|
||||
const Badge: React.FC<BadgeProps> = ({
|
||||
variant = "light",
|
||||
color = "primary",
|
||||
size = "md",
|
||||
startIcon,
|
||||
endIcon,
|
||||
children,
|
||||
}) => {
|
||||
const baseStyles =
|
||||
"inline-flex items-center px-2.5 py-0.5 justify-center gap-1 rounded-full font-medium";
|
||||
|
||||
// Define size styles
|
||||
const sizeStyles = {
|
||||
sm: "text-theme-xs", // Smaller padding and font size
|
||||
md: "text-sm", // Default padding and font size
|
||||
};
|
||||
|
||||
// Define color styles for variants
|
||||
const variants = {
|
||||
light: {
|
||||
primary:
|
||||
"bg-brand-50 text-brand-500 dark:bg-brand-500/15 dark:text-brand-400",
|
||||
success:
|
||||
"bg-success-50 text-success-600 dark:bg-success-500/15 dark:text-success-500",
|
||||
error:
|
||||
"bg-error-50 text-error-600 dark:bg-error-500/15 dark:text-error-500",
|
||||
warning:
|
||||
"bg-warning-50 text-warning-600 dark:bg-warning-500/15 dark:text-orange-400",
|
||||
info: "bg-blue-light-50 text-blue-light-500 dark:bg-blue-light-500/15 dark:text-blue-light-500",
|
||||
light: "bg-gray-100 text-gray-700 dark:bg-white/5 dark:text-white/80",
|
||||
dark: "bg-gray-500 text-white dark:bg-white/5 dark:text-white",
|
||||
},
|
||||
solid: {
|
||||
primary: "bg-brand-500 text-white dark:text-white",
|
||||
success: "bg-success-500 text-white dark:text-white",
|
||||
error: "bg-error-500 text-white dark:text-white",
|
||||
warning: "bg-warning-500 text-white dark:text-white",
|
||||
info: "bg-blue-light-500 text-white dark:text-white",
|
||||
light: "bg-gray-400 dark:bg-white/5 text-white dark:text-white/80",
|
||||
dark: "bg-gray-700 text-white dark:text-white",
|
||||
},
|
||||
};
|
||||
|
||||
// Get styles based on size and color variant
|
||||
const sizeClass = sizeStyles[size];
|
||||
const colorStyles = variants[variant][color];
|
||||
|
||||
return (
|
||||
<span className={`${baseStyles} ${sizeClass} ${colorStyles}`}>
|
||||
{startIcon && <span className="mr-1">{startIcon}</span>}
|
||||
{children}
|
||||
{endIcon && <span className="ml-1">{endIcon}</span>}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default Badge;
|
||||
@@ -0,0 +1,57 @@
|
||||
import React, { ReactNode } from "react";
|
||||
|
||||
interface ButtonProps {
|
||||
children: ReactNode; // Button text or content
|
||||
size?: "sm" | "md"; // Button size
|
||||
variant?: "primary" | "outline"; // Button variant
|
||||
startIcon?: ReactNode; // Icon before the text
|
||||
endIcon?: ReactNode; // Icon after the text
|
||||
onClick?: () => void; // Click handler
|
||||
disabled?: boolean; // Disabled state
|
||||
className?: string; // Disabled state
|
||||
type?: "button" | "submit" | "reset";
|
||||
}
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
children,
|
||||
size = "md",
|
||||
variant = "primary",
|
||||
startIcon,
|
||||
endIcon,
|
||||
onClick,
|
||||
className = "",
|
||||
disabled = false,
|
||||
type = "button",
|
||||
}) => {
|
||||
// Size Classes
|
||||
const sizeClasses = {
|
||||
sm: "px-4 py-3 text-sm",
|
||||
md: "px-5 py-3.5 text-sm",
|
||||
};
|
||||
|
||||
// Variant Classes
|
||||
const variantClasses = {
|
||||
primary:
|
||||
"bg-brand-500 text-white shadow-theme-xs hover:bg-brand-600 disabled:bg-brand-300",
|
||||
outline:
|
||||
"bg-white text-gray-700 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700 dark:hover:bg-white/[0.03] dark:hover:text-gray-300",
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`inline-flex items-center justify-center font-medium gap-2 rounded-lg transition ${className} ${
|
||||
sizeClasses[size]
|
||||
} ${variantClasses[variant]} ${
|
||||
disabled ? "cursor-not-allowed opacity-50" : ""
|
||||
}`}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{startIcon && <span className="flex items-center">{startIcon}</span>}
|
||||
{children}
|
||||
{endIcon && <span className="flex items-center">{endIcon}</span>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
import type React from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface DropdownProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Dropdown: React.FC<DropdownProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
className = "",
|
||||
}) => {
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
!(event.target as HTMLElement).closest('.dropdown-toggle')
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className={`absolute z-40 right-0 mt-2 rounded-xl border border-gray-200 bg-white shadow-theme-lg dark:border-gray-800 dark:bg-gray-dark ${className}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import type React from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
interface DropdownItemProps {
|
||||
tag?: "a" | "button";
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
onItemClick?: () => void;
|
||||
baseClassName?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DropdownItem: React.FC<DropdownItemProps> = ({
|
||||
tag = "button",
|
||||
href,
|
||||
onClick,
|
||||
onItemClick,
|
||||
baseClassName = "block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 hover:text-gray-900",
|
||||
className = "",
|
||||
children,
|
||||
}) => {
|
||||
const combinedClasses = `${baseClassName} ${className}`.trim();
|
||||
|
||||
const handleClick = (event: React.MouseEvent) => {
|
||||
if (tag === "button") {
|
||||
event.preventDefault();
|
||||
}
|
||||
if (onClick) onClick();
|
||||
if (onItemClick) onItemClick();
|
||||
};
|
||||
|
||||
if (tag === "a" && href) {
|
||||
return (
|
||||
<Link href={href} className={combinedClasses} onClick={handleClick}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button onClick={handleClick} className={combinedClasses}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
|
||||
export default function ResponsiveImage() {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="overflow-hidden">
|
||||
<Image
|
||||
src="/images/grid-image/image-01.png"
|
||||
alt="Cover"
|
||||
className="w-full border border-gray-200 rounded-xl dark:border-gray-800"
|
||||
width={1054}
|
||||
height={600}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
|
||||
export default function ThreeColumnImageGrid() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<div>
|
||||
<Image
|
||||
src="/images/grid-image/image-04.png"
|
||||
alt=" grid"
|
||||
className="w-full border border-gray-200 rounded-xl dark:border-gray-800"
|
||||
width={338}
|
||||
height={192}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Image
|
||||
src="/images/grid-image/image-05.png"
|
||||
alt=" grid"
|
||||
className="w-full border border-gray-200 rounded-xl dark:border-gray-800"
|
||||
width={338}
|
||||
height={192}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Image
|
||||
src="/images/grid-image/image-06.png"
|
||||
alt=" grid"
|
||||
className="w-full border border-gray-200 rounded-xl dark:border-gray-800"
|
||||
width={338}
|
||||
height={192}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Image from "next/image";
|
||||
import React from "react";
|
||||
|
||||
export default function TwoColumnImageGrid() {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<div>
|
||||
<Image
|
||||
src="/images/grid-image/image-02.png"
|
||||
alt=" grid"
|
||||
className="w-full border border-gray-200 rounded-xl dark:border-gray-800"
|
||||
width={517}
|
||||
height={295}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Image
|
||||
src="/images/grid-image/image-03.png"
|
||||
alt=" grid"
|
||||
className="w-full border border-gray-200 rounded-xl dark:border-gray-800"
|
||||
width={517}
|
||||
height={295}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
import React, { useRef, useEffect } from "react";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
showCloseButton?: boolean;
|
||||
isFullscreen?: boolean;
|
||||
}
|
||||
|
||||
export const Modal: React.FC<ModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
isFullscreen = false,
|
||||
}) => {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "unset";
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const contentClasses = isFullscreen
|
||||
? "w-full h-full"
|
||||
: "relative w-full rounded-xl bg-white dark:bg-gray-900";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center overflow-y-auto modal z-99999">
|
||||
{!isFullscreen && (
|
||||
<div
|
||||
className="fixed inset-0 h-full w-full bg-black/50 backdrop-blur-[2px]"
|
||||
onClick={onClose}
|
||||
></div>
|
||||
)}
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={`${contentClasses} ${className}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{showCloseButton && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-3 top-3 z-999 flex h-9.5 w-9.5 items-center justify-center rounded-full bg-gray-100 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-white sm:right-6 sm:top-6 sm:h-11 sm:w-11"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.04289 16.5413C5.65237 16.9318 5.65237 17.565 6.04289 17.9555C6.43342 18.346 7.06658 18.346 7.45711 17.9555L11.9987 13.4139L16.5408 17.956C16.9313 18.3466 17.5645 18.3466 17.955 17.956C18.3455 17.5655 18.3455 16.9323 17.955 16.5418L13.4129 11.9997L17.955 7.4576C18.3455 7.06707 18.3455 6.43391 17.955 6.04338C17.5645 5.65286 16.9313 5.65286 16.5408 6.04338L11.9987 10.5855L7.45711 6.0439C7.06658 5.65338 6.43342 5.65338 6.04289 6.0439C5.65237 6.43442 5.65237 7.06759 6.04289 7.45811L10.5845 11.9997L6.04289 16.5413Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
"use client";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
// Component này đóng vai trò như một "vùng an toàn"
|
||||
export function SafeHTMLRenderer({ html }: { html: string }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current) {
|
||||
// 1. Giải mã HTML Entities (Biến < thành <, " thành ", ...)
|
||||
const txt = document.createElement("textarea");
|
||||
txt.innerHTML = html;
|
||||
let decoded = txt.value;
|
||||
|
||||
// 2. Xử lý xóa thẻ <pre> bọc ngoài nếu API trả về dư thừa
|
||||
decoded = decoded.replace(/<pre[^>]*>/g, "").replace(/<\/pre>/g, "");
|
||||
|
||||
// 3. Khởi tạo Shadow DOM (nếu chưa có)
|
||||
const shadowRoot = containerRef.current.shadowRoot || containerRef.current.attachShadow({ mode: "open" });
|
||||
|
||||
// 4. Render nội dung vào trong vùng cô lập
|
||||
shadowRoot.innerHTML = decoded;
|
||||
}
|
||||
}, [html]);
|
||||
|
||||
return <div ref={containerRef} className="w-full h-auto" />;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import React, { ReactNode, TdHTMLAttributes, HTMLAttributes } from "react";
|
||||
|
||||
// Props for Table
|
||||
interface TableProps extends HTMLAttributes<HTMLTableElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Props for TableHeader
|
||||
interface TableHeaderProps extends HTMLAttributes<HTMLTableSectionElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Props for TableBody
|
||||
interface TableBodyProps extends HTMLAttributes<HTMLTableSectionElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Props for TableRow
|
||||
interface TableRowProps extends HTMLAttributes<HTMLTableRowElement> {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Props for TableCell - Hỗ trợ colSpan, rowSpan...
|
||||
interface TableCellProps extends TdHTMLAttributes<HTMLTableCellElement> {
|
||||
children: ReactNode;
|
||||
isHeader?: boolean;
|
||||
}
|
||||
|
||||
// Table Component
|
||||
const Table: React.FC<TableProps> = ({ children, className, ...props }) => {
|
||||
return <table className={`min-w-full ${className}`} {...props}>{children}</table>;
|
||||
};
|
||||
|
||||
// TableHeader Component
|
||||
const TableHeader: React.FC<TableHeaderProps> = ({ children, className, ...props }) => {
|
||||
return <thead className={className} {...props}>{children}</thead>;
|
||||
};
|
||||
|
||||
// TableBody Component
|
||||
const TableBody: React.FC<TableBodyProps> = ({ children, className, ...props }) => {
|
||||
return <tbody className={className} {...props}>{children}</tbody>;
|
||||
};
|
||||
|
||||
// TableRow Component
|
||||
const TableRow: React.FC<TableRowProps> = ({ children, className, ...props }) => {
|
||||
return <tr className={className} {...props}>{children}</tr>;
|
||||
};
|
||||
|
||||
// TableCell Component
|
||||
const TableCell: React.FC<TableCellProps> = ({
|
||||
children,
|
||||
isHeader = false,
|
||||
className,
|
||||
...props
|
||||
}) => {
|
||||
const CellTag = isHeader ? "th" : "td";
|
||||
return (
|
||||
<CellTag className={className} {...(props as any)}>
|
||||
{children}
|
||||
</CellTag>
|
||||
);
|
||||
};
|
||||
|
||||
export { Table, TableHeader, TableBody, TableRow, TableCell };
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from "react";
|
||||
import YouTubeEmbed from "./YouTubeEmbed";
|
||||
import ComponentCard from "@/components/common/ComponentCard";
|
||||
|
||||
export default function VideosExample() {
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-1 gap-5 sm:gap-6 xl:grid-cols-2">
|
||||
<div className="space-y-5 sm:space-y-6">
|
||||
<ComponentCard title="Video Ratio 16:9">
|
||||
<YouTubeEmbed videoId="dQw4w9WgXcQ" />
|
||||
</ComponentCard>
|
||||
<ComponentCard title="Video Ratio 4:3">
|
||||
<YouTubeEmbed videoId="dQw4w9WgXcQ" aspectRatio="4:3" />
|
||||
</ComponentCard>
|
||||
</div>
|
||||
<div className="space-y-5 sm:space-y-6">
|
||||
<ComponentCard title="Video Ratio 21:9">
|
||||
<YouTubeEmbed videoId="dQw4w9WgXcQ" aspectRatio="21:9" />
|
||||
</ComponentCard>
|
||||
<ComponentCard title="Video Ratio 1:1">
|
||||
<YouTubeEmbed videoId="dQw4w9WgXcQ" aspectRatio="1:1" />
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React from "react";
|
||||
|
||||
type AspectRatio = "16:9" | "4:3" | "21:9" | "1:1";
|
||||
|
||||
interface YouTubeEmbedProps {
|
||||
videoId: string;
|
||||
aspectRatio?: AspectRatio;
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const YouTubeEmbed: React.FC<YouTubeEmbedProps> = ({
|
||||
videoId,
|
||||
aspectRatio = "16:9",
|
||||
title = "YouTube video",
|
||||
className = "",
|
||||
}) => {
|
||||
const aspectRatioClass = {
|
||||
"16:9": "aspect-video",
|
||||
"4:3": "aspect-4/3",
|
||||
"21:9": "aspect-21/9",
|
||||
"1:1": "aspect-square",
|
||||
}[aspectRatio];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`overflow-hidden rounded-lg ${aspectRatioClass} ${className}`}
|
||||
>
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${videoId}`}
|
||||
title={title}
|
||||
frameBorder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
className="w-full h-full"
|
||||
></iframe>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default YouTubeEmbed;
|
||||
Reference in New Issue
Block a user