initial commit after project creation
This commit is contained in:
47
src/components/ui/admin-link.tsx
Normal file
47
src/components/ui/admin-link.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useEditState } from "tinacms/dist/react";
|
||||
|
||||
const AdminLink = () => {
|
||||
const { edit } = useEditState();
|
||||
const [showAdminLink, setShowAdminLink] = React.useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setShowAdminLink(
|
||||
!edit &&
|
||||
JSON.parse((window.localStorage.getItem("tinacms-auth") as any) || "{}")
|
||||
?.access_token
|
||||
);
|
||||
}, [edit]);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setShowAdminLink(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showAdminLink && (
|
||||
<div className="fixed right-4 top-4 z-50 flex items-center justify-between rounded-full bg-blue-500 px-3 py-1 text-white">
|
||||
<a
|
||||
href={`/admin/index.html#/~${window.location.pathname}`}
|
||||
className="text-xs"
|
||||
>
|
||||
Edit This Page
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
className="ml-2 text-sm"
|
||||
>
|
||||
<XMarkIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminLink;
|
||||
130
src/components/ui/buttons.tsx
Normal file
130
src/components/ui/buttons.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import Link from "next/link";
|
||||
import type React from "react";
|
||||
|
||||
interface ButtonProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
color?: "white" | "blue" | "orange" | "seafoam" | "ghost" | "ghostBlue";
|
||||
size?: "large" | "small" | "medium" | "extraSmall";
|
||||
className?: string;
|
||||
href?: string;
|
||||
type?: "button" | "submit" | "reset";
|
||||
children: React.ReactNode | React.ReactNode[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const baseClasses =
|
||||
"transition duration-150 ease-out rounded-full flex items-center font-tuner whitespace-nowrap leading-snug focus:outline-none focus:shadow-outline hover:-translate-y-px active:translate-y-px hover:-translate-x-px active:translate-x-px leading-tight";
|
||||
|
||||
const raisedButtonClasses = "hover:shadow active:shadow-none";
|
||||
|
||||
const colorClasses = {
|
||||
seafoam: `${raisedButtonClasses} text-orange-600 hover:text-orange-500 border border-seafoam-150 bg-gradient-to-br from-seafoam-50 to-seafoam-150`,
|
||||
blue: `${raisedButtonClasses} text-white hover:text-gray-50 border border-blue-400 bg-gradient-to-br from-blue-300 via-blue-400 to-blue-600`,
|
||||
orange: `${raisedButtonClasses} text-white hover:text-gray-50 border border-orange-600 bg-gradient-to-br from-orange-400 to-orange-600`,
|
||||
white: `${raisedButtonClasses} text-orange-500 hover:text-orange-400 border border-gray-100/60 bg-gradient-to-br from-white to-gray-50`,
|
||||
ghost: "text-orange-500 hover:text-orange-400",
|
||||
orangeWithBorder:
|
||||
"text-orange-500 hover:text-orange-400 border border-orange-500 bg-white",
|
||||
ghostBlue: "text-blue-800 hover:text-blue-800",
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
large: "px-8 pt-[14px] pb-[12px] text-lg font-medium",
|
||||
medium: "px-6 pt-[12px] pb-[10px] text-base font-medium",
|
||||
small: "px-5 pt-[10px] pb-[8px] text-sm font-medium",
|
||||
extraSmall: "px-4 pt-[8px] pb-[6px] text-xs font-medium",
|
||||
};
|
||||
|
||||
export const Button = ({
|
||||
color = "seafoam",
|
||||
size = "medium",
|
||||
className = "",
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
className={`${baseClasses} ${
|
||||
colorClasses[color] ? colorClasses[color] : colorClasses.seafoam
|
||||
} ${
|
||||
sizeClasses[size] ? sizeClasses[size] : sizeClasses.medium
|
||||
} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const LinkButton = ({
|
||||
link = "/",
|
||||
color = "seafoam",
|
||||
size = "medium",
|
||||
className = "",
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Link
|
||||
href={link}
|
||||
passHref
|
||||
className={`${baseClasses} ${
|
||||
colorClasses[color] ? colorClasses[color] : colorClasses.seafoam
|
||||
} ${
|
||||
sizeClasses[size] ? sizeClasses[size] : sizeClasses.medium
|
||||
} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export const FlushButton = ({
|
||||
link = "/",
|
||||
color = "seafoam",
|
||||
className = "",
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<Link
|
||||
href={link}
|
||||
passHref
|
||||
className={`${baseClasses} ${
|
||||
colorClasses[color] ? colorClasses[color] : colorClasses.seafoam
|
||||
} ${"hover:inner-link border-none bg-none p-2 hover:translate-x-0 hover:translate-y-0 hover:shadow-none"} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModalButton = ({
|
||||
color = "seafoam",
|
||||
size = "medium",
|
||||
className = "",
|
||||
children,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className={`${baseClasses} ${
|
||||
colorClasses[color] ? colorClasses[color] : colorClasses.seafoam
|
||||
} ${
|
||||
sizeClasses[size] ? sizeClasses[size] : sizeClasses.medium
|
||||
} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const ButtonGroup = ({ children }) => {
|
||||
return (
|
||||
<div className="flex w-full flex-wrap items-center justify-start gap-4">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
60
src/components/ui/custom-color-toggle.tsx
Normal file
60
src/components/ui/custom-color-toggle.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React from "react";
|
||||
|
||||
export const CustomColorToggle = ({ input }) => {
|
||||
const { value = {}, onChange } = input;
|
||||
const disableColor = value.disableColor || false;
|
||||
const colorValue = value.colorValue || "#000000";
|
||||
|
||||
const handleCheckboxChange = (e) => {
|
||||
onChange({ ...value, disableColor: e.target.checked });
|
||||
};
|
||||
|
||||
const handleColorChange = (e) => {
|
||||
onChange({ ...value, colorValue: e.target.value });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<label className="mb-2 block text-xs font-semibold text-gray-700">
|
||||
Custom Background Selector
|
||||
</label>
|
||||
<div className="flex items-center pt-2">
|
||||
<label className="flex cursor-pointer items-center">
|
||||
<div className="relative shadow-lg">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={disableColor}
|
||||
onChange={handleCheckboxChange}
|
||||
className="sr-only"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`h-5 w-10 rounded-full shadow-inner transition-colors duration-200 ${
|
||||
disableColor ? "bg-green-500" : "bg-gray-300"
|
||||
}`}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`absolute left-0 top-0 size-5 rounded-full bg-white shadow transition-transform duration-200 ${
|
||||
disableColor ? "translate-x-full" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="ml-3 text-gray-700">
|
||||
Tick to use Default Background Color
|
||||
</span>
|
||||
</label>
|
||||
{/* Color Picker */}
|
||||
<div style={{ marginLeft: "1rem", opacity: disableColor ? 0.5 : 1 }}>
|
||||
<input
|
||||
type="color"
|
||||
value={colorValue}
|
||||
onChange={handleColorChange}
|
||||
disabled={disableColor}
|
||||
className="size-10 rounded border border-gray-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
91
src/components/ui/custom-dropdown.tsx
Normal file
91
src/components/ui/custom-dropdown.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@radix-ui/react-dropdown-menu";
|
||||
import React, { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { MdArrowDropDown } from "react-icons/md";
|
||||
|
||||
export interface DropdownOption {
|
||||
value: string;
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
interface CustomDropdownProps {
|
||||
/** The currently selected value */
|
||||
value: string;
|
||||
/** Function fired when a new option is selected */
|
||||
onChange: (value: string) => void;
|
||||
/** List of options to choose from */
|
||||
options: DropdownOption[];
|
||||
/** Placeholder text shown when no option is selected */
|
||||
placeholder?: string;
|
||||
/** Whether the dropdown is disabled */
|
||||
disabled?: boolean;
|
||||
/** Additional classes for the trigger button */
|
||||
className?: string;
|
||||
/** Additional classes for the dropdown content */
|
||||
contentClassName?: string;
|
||||
/** Additional classes for each menu item */
|
||||
itemClassName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable dropdown component built with Radix UI.
|
||||
*
|
||||
* It matches the full width of its trigger and automatically rotates the chevron icon when open.
|
||||
*/
|
||||
export const CustomDropdown = ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
placeholder = "Select an option",
|
||||
disabled = false,
|
||||
className = "",
|
||||
contentClassName = "",
|
||||
itemClassName = "",
|
||||
}: CustomDropdownProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// Find the label for the current value.
|
||||
const activeOption = options.find((opt) => opt.value === value);
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={setIsOpen} open={disabled ? false : isOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
className={`w-full p-2 border border-gray-300 rounded-md shadow-sm text-neutral hover:bg-neutral-background-secondary focus:outline-none flex items-center justify-between gap-2 max-w-full overflow-x-hidden ${
|
||||
disabled ? "opacity-50 cursor-not-allowed" : "hover:bg-gray-50"
|
||||
} ${className}`}
|
||||
>
|
||||
<span className="truncate break-words whitespace-normal max-w-full text-left">
|
||||
{activeOption ? activeOption.label : placeholder}
|
||||
</span>
|
||||
<MdArrowDropDown
|
||||
className={`w-5 h-5 transition-transform duration-200 ${
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
} ${disabled ? "opacity-50" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent
|
||||
className={`z-50 max-h-60 overflow-y-auto w-[var(--radix-dropdown-menu-trigger-width)] min-w-[200px] bg-white border border-gray-200 rounded-md shadow-lg ${contentClassName}`}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<DropdownMenuItem
|
||||
key={opt.value}
|
||||
onClick={() => onChange(opt.value)}
|
||||
className={`px-3 py-2 cursor-pointer truncate break-words whitespace-normal max-w-full w-full focus:outline-none focus:ring-0 hover:bg-gray-100 ${itemClassName}`}
|
||||
>
|
||||
{opt.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
27
src/components/ui/dynamic-link.tsx
Normal file
27
src/components/ui/dynamic-link.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import Link, { type LinkProps } from "next/link";
|
||||
import type React from "react";
|
||||
|
||||
type ExtraProps = Omit<LinkProps, "as" | "href">;
|
||||
|
||||
interface DynamicLinkProps extends ExtraProps {
|
||||
href: string;
|
||||
children?: React.ReactNode;
|
||||
isFullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const DynamicLink = ({
|
||||
href,
|
||||
children,
|
||||
isFullWidth = false,
|
||||
...props
|
||||
}: DynamicLinkProps) => {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
{...props}
|
||||
className={`cursor-pointer ${isFullWidth ? "" : ""}`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
166
src/components/ui/image-overlay-wrapper.tsx
Normal file
166
src/components/ui/image-overlay-wrapper.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import Image, { type ImageLoader } from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { MdClose } from "react-icons/md";
|
||||
|
||||
interface ImageOverlayWrapperProps {
|
||||
children: React.ReactNode;
|
||||
src: string;
|
||||
alt: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
// Custom image loader to bypass Next.js image optimization
|
||||
const customImageLoader: ImageLoader = ({ src, width, quality }) => {
|
||||
// If it's already an absolute URL (starts with http:// or https://), return as-is
|
||||
if (src.startsWith("http://") || src.startsWith("https://")) {
|
||||
return src;
|
||||
}
|
||||
|
||||
// For relative paths, prepend the base path if it exists
|
||||
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || "";
|
||||
const fullSrc = `${basePath}${src}`;
|
||||
|
||||
// If the src already includes query parameters, append with &, otherwise use ?
|
||||
const separator = fullSrc.includes("?") ? "&" : "?";
|
||||
return `${fullSrc}${separator}w=${width}&q=${quality || 75}`;
|
||||
};
|
||||
|
||||
export const ImageOverlayWrapper = ({
|
||||
children,
|
||||
src,
|
||||
alt,
|
||||
caption,
|
||||
}: ImageOverlayWrapperProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Disable scrolling when overlay is open
|
||||
document.body.style.overflow = "hidden";
|
||||
|
||||
// Focus the overlay for keyboard interaction
|
||||
if (overlayRef.current) {
|
||||
overlayRef.current.focus();
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = "unset";
|
||||
};
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const openOverlay = () => {
|
||||
setIsOpen(true);
|
||||
setIsLoading(true); // Reset loading state when opening overlay
|
||||
};
|
||||
|
||||
const closeOverlay = () => setIsOpen(false);
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
closeOverlay();
|
||||
}
|
||||
};
|
||||
|
||||
const overlay =
|
||||
isOpen && mounted
|
||||
? createPortal(
|
||||
<div
|
||||
ref={overlayRef}
|
||||
tabIndex={-1}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center backdrop-blur-lg outline-none"
|
||||
onClick={closeOverlay}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeOverlay}
|
||||
className="absolute top-4 right-4 z-10 flex items-center justify-center w-10 h-10 border border-brand-primary hover:border-brand-primary-hover bg-neutral-background-secondary hover:bg-neutral-background-secondary/80 rounded-full transition-colors duration-200 group"
|
||||
aria-label="Close image overlay"
|
||||
>
|
||||
<MdClose className="w-6 h-6 text-neutral-text group-hover:text-neutral-text-secondary" />
|
||||
</button>
|
||||
|
||||
{/* Image container */}
|
||||
<div className="relative max-w-[90vw] max-h-[90vh] flex items-center justify-center p-8">
|
||||
<div className="relative flex flex-col items-center justify-center">
|
||||
<div
|
||||
className="relative w-[80vw] h-[80vh] overflow-hidden"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Loading skeleton */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-neutral-background-secondary/50 rounded-lg">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-12 h-12 border-4 border-brand-primary border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-neutral-text-secondary text-sm">
|
||||
Loading image...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Image
|
||||
loader={customImageLoader}
|
||||
src={src}
|
||||
alt={alt}
|
||||
fill
|
||||
style={{ objectFit: "contain", objectPosition: "center" }}
|
||||
onLoad={handleImageLoad}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Caption */}
|
||||
{caption && (
|
||||
<div
|
||||
className="mt-4 px-4 py-2 rounded-lg bg-neutral-background"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<p className="text-neutral-text text-sm text-center font-light">
|
||||
{caption}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Click anywhere to close hint */}
|
||||
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2">
|
||||
<p className="text-neutral-text-secondary text-sm">
|
||||
Click anywhere to close
|
||||
</p>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openOverlay}
|
||||
className="cursor-pointer transition-opacity duration-200 hover:opacity-80 active:opacity-90 border-none bg-transparent p-0 md:block w-full flex justify-center"
|
||||
aria-label={`Open image overlay: ${alt}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
{overlay}
|
||||
</>
|
||||
);
|
||||
};
|
||||
37
src/components/ui/light-dark-switch.tsx
Normal file
37
src/components/ui/light-dark-switch.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { IoMoon, IoSunny } from "react-icons/io5";
|
||||
|
||||
export default function LightDarkSwitch() {
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
const isLight = resolvedTheme === "light";
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center w-10 h-10 rounded-full transition-all duration-300 ease-in-out cursor-pointer"
|
||||
onClick={() => setTheme(isLight ? "dark" : "light")}
|
||||
>
|
||||
{mounted ? (
|
||||
isLight ? (
|
||||
<IoSunny
|
||||
size={20}
|
||||
className="text-brand-primary transition-colors duration-300"
|
||||
/>
|
||||
) : (
|
||||
<IoMoon
|
||||
size={19}
|
||||
className="text-neutral-text transition-colors duration-300"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="w-5 h-5 rounded-full animate-pulse opacity-20" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
127
src/components/ui/pagination.tsx
Normal file
127
src/components/ui/pagination.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { usePathname } from "next/navigation";
|
||||
import React from "react";
|
||||
import { MdChevronLeft, MdChevronRight } from "react-icons/md";
|
||||
import { useNavigation } from "../docs/layout/navigation-context";
|
||||
import { DynamicLink } from "./dynamic-link";
|
||||
|
||||
export function Pagination() {
|
||||
const [prevPage, setPrevPage] = React.useState<any>(null);
|
||||
const [nextPage, setNextPage] = React.useState<any>(null);
|
||||
const pathname = usePathname();
|
||||
const docsData = useNavigation();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!docsData?.data) return;
|
||||
|
||||
// Flatten the hierarchical structure into a linear array
|
||||
const flattenItems = (items: any[]): any[] => {
|
||||
const flattened: any[] = [];
|
||||
|
||||
const traverse = (itemList: any[]) => {
|
||||
for (const item of itemList) {
|
||||
if (item.slug) {
|
||||
flattened.push({
|
||||
slug: item.slug.id,
|
||||
title: item.slug.title,
|
||||
});
|
||||
}
|
||||
if (item.items) {
|
||||
// This has nested items, traverse them
|
||||
traverse(item.items);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
traverse(items);
|
||||
return flattened;
|
||||
};
|
||||
|
||||
const getAllPages = (): any[] => {
|
||||
const allPages: any[] = [];
|
||||
|
||||
for (const tab of docsData.data) {
|
||||
if (tab.items) {
|
||||
const flattenedItems = flattenItems(tab.items);
|
||||
allPages.push(...flattenedItems);
|
||||
}
|
||||
}
|
||||
|
||||
return allPages;
|
||||
};
|
||||
|
||||
// Get current slug from pathname
|
||||
const slug =
|
||||
pathname === "/docs"
|
||||
? "content/docs/index.mdx"
|
||||
: `content${pathname}.mdx`;
|
||||
|
||||
// Get all pages in sequence
|
||||
const allPages = getAllPages();
|
||||
|
||||
// Find current page index
|
||||
const currentIndex = allPages.findIndex((page: any) => page.slug === slug);
|
||||
|
||||
if (currentIndex !== -1) {
|
||||
// Set previous page (if exists)
|
||||
const prev = currentIndex > 0 ? allPages[currentIndex - 1] : null;
|
||||
setPrevPage(prev);
|
||||
|
||||
// Set next page (if exists)
|
||||
const next =
|
||||
currentIndex < allPages.length - 1 ? allPages[currentIndex + 1] : null;
|
||||
setNextPage(next);
|
||||
} else {
|
||||
setPrevPage(null);
|
||||
setNextPage(null);
|
||||
}
|
||||
}, [docsData, pathname]);
|
||||
|
||||
return (
|
||||
<div className="flex justify-between mt-2 py-4 rounded-lg gap-4 w-full">
|
||||
{prevPage?.slug ? (
|
||||
//Slices to remove content/ and .mdx from the filepath, and removes /index for index pages
|
||||
<DynamicLink
|
||||
href={prevPage.slug.slice(7, -4).replace(/\/index$/, "/")}
|
||||
passHref
|
||||
>
|
||||
<div className="group relative block cursor-pointer py-4 text-left transition-all">
|
||||
<span className="pl-10 text-sm uppercase opacity-50 group-hover:opacity-100 text-neutral-text-secondary">
|
||||
Previous
|
||||
</span>
|
||||
<h5 className="pl m-0 flex items-center font-light leading-[1.3] text-brand-secondary opacity-80 group-hover:opacity-100 transition-all duration-150 ease-out group-hover:text-brand-primary md:text-xl">
|
||||
<MdChevronLeft className="ml-2 size-7 fill-gray-400 transition-all duration-150 ease-out group-hover:fill-brand-primary" />
|
||||
<span className="relative brand-secondary-gradient">
|
||||
{prevPage.title}
|
||||
<span className="absolute bottom-0 left-0 w-0 h-[1.5px] bg-gradient-to-r from-brand-secondary-gradient-start to-brand-secondary-gradient-end group-hover:w-full transition-all duration-300 ease-in-out" />
|
||||
</span>
|
||||
</h5>
|
||||
</div>
|
||||
</DynamicLink>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{nextPage?.slug ? (
|
||||
//Slices to remove content/ and .mdx from the filepath, and removes /index for index pages
|
||||
<DynamicLink
|
||||
href={nextPage.slug.slice(7, -4).replace(/\/index$/, "/")}
|
||||
passHref
|
||||
>
|
||||
<div className="group relative col-start-2 block cursor-pointer p-4 text-right transition-all">
|
||||
<span className="pr-6 text-sm uppercase opacity-50 md:pr-10 group-hover:opacity-100 text-neutral-text-secondary">
|
||||
Next
|
||||
</span>
|
||||
<h5 className="m-0 flex items-center justify-end font-light leading-[1.3] text-brand-secondary opacity-80 group-hover:opacity-100 transition-all duration-150 ease-out group-hover:text-brand-primary md:text-xl">
|
||||
<span className="relative brand-secondary-gradient">
|
||||
{nextPage.title}
|
||||
<span className="absolute bottom-0 left-0 w-0 h-[1.5px] bg-gradient-to-r from-brand-secondary-gradient-start to-brand-secondary-gradient-end group-hover:w-full transition-all duration-300 ease-in-out" />
|
||||
</span>
|
||||
<MdChevronRight className="ml-2 size-7 fill-gray-400 transition-all duration-150 ease-out group-hover:fill-brand-primary" />
|
||||
</h5>
|
||||
</div>
|
||||
</DynamicLink>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
src/components/ui/tailwind-indicator.tsx
Normal file
14
src/components/ui/tailwind-indicator.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export function TailwindIndicator() {
|
||||
if (process.env.NODE_ENV === "production") return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-1 left-1 z-50 flex h-6 w-6 items-center justify-center rounded-full bg-gray-800 p-3 font-mono text-xs text-white">
|
||||
<div className="block sm:hidden">xs</div>
|
||||
<div className="hidden sm:block md:hidden">sm</div>
|
||||
<div className="hidden md:block lg:hidden">md</div>
|
||||
<div className="hidden lg:block xl:hidden">lg</div>
|
||||
<div className="hidden xl:block 2xl:hidden">xl</div>
|
||||
<div className="hidden 2xl:block">2xl</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
src/components/ui/theme-selector.tsx
Normal file
180
src/components/ui/theme-selector.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MdArrowDropDown } from "react-icons/md";
|
||||
import { MdHelpOutline } from "react-icons/md";
|
||||
|
||||
const themes = ["default", "tina", "blossom", "lake", "pine", "indigo"];
|
||||
|
||||
export const BROWSER_TAB_THEME_KEY = "browser-tab-theme";
|
||||
|
||||
// Default theme colors from root
|
||||
const DEFAULT_COLORS = {
|
||||
background: "#FFFFFF",
|
||||
text: "#000000",
|
||||
border: "#000000",
|
||||
};
|
||||
|
||||
export const ThemeSelector = () => {
|
||||
const { theme, setTheme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedTheme, setSelectedTheme] = useState(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return sessionStorage.getItem(BROWSER_TAB_THEME_KEY) || theme;
|
||||
}
|
||||
return theme;
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
if (
|
||||
tooltipRef.current &&
|
||||
!tooltipRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowTooltip(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Avoid hydration mismatch
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Update selected theme when theme changes from dropdown
|
||||
useEffect(() => {
|
||||
if (theme && !themes.includes(theme)) {
|
||||
// If theme is not in our list, it means it's a dark/light mode change
|
||||
setSelectedTheme(selectedTheme);
|
||||
} else {
|
||||
setSelectedTheme(theme);
|
||||
}
|
||||
}, [theme, selectedTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mounted && selectedTheme) {
|
||||
const isDark = resolvedTheme === "dark";
|
||||
document.documentElement.className = `theme-${selectedTheme}${
|
||||
isDark ? " dark" : ""
|
||||
}`;
|
||||
sessionStorage.setItem(BROWSER_TAB_THEME_KEY, selectedTheme);
|
||||
}
|
||||
}, [selectedTheme, resolvedTheme, mounted]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const handleThemeChange = (newTheme: string) => {
|
||||
const currentMode = resolvedTheme;
|
||||
setSelectedTheme(newTheme);
|
||||
sessionStorage.setItem(BROWSER_TAB_THEME_KEY, newTheme);
|
||||
setIsOpen(false);
|
||||
if (currentMode === "dark") {
|
||||
setTheme("light");
|
||||
setTimeout(() => setTheme("dark"), 0);
|
||||
} else {
|
||||
setTheme("light");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 bg-neutral-surface p-1 rounded-lg shadow-lg">
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative" ref={tooltipRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTooltip(!showTooltip)}
|
||||
className="w-6 h-6 rounded-full bg-neutral-hover hover:bg-neutral-border flex items-center justify-center text-neutral-text transition-colors"
|
||||
aria-label="Theme help"
|
||||
>
|
||||
<MdHelpOutline className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{showTooltip && <Tooltip selectedTheme={selectedTheme} />}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-[120px] rounded-md border border-neutral-border bg-neutral-surface px-3 py-1 text-sm text-neutral-text focus:outline-none focus:ring-2 focus:ring-brand-primary flex items-center justify-between cursor-pointer"
|
||||
>
|
||||
<span className="truncate">
|
||||
{selectedTheme.charAt(0).toUpperCase() + selectedTheme.slice(1)}
|
||||
</span>
|
||||
<MdArrowDropDown
|
||||
className={`w-4 h-4 text-brand-secondary-dark-dark transition-transform duration-200 flex-shrink-0 ${
|
||||
isOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute bottom-full left-0 right-0 mb-1 bg-neutral-surface rounded-md border border-neutral-border shadow-lg overflow-hidden w-[120px]">
|
||||
{themes.map((t) => (
|
||||
<button
|
||||
type="button"
|
||||
key={t}
|
||||
onClick={() => handleThemeChange(t)}
|
||||
className={`w-full px-3 py-1 text-sm text-left hover:bg-neutral-hover transition-colors cursor-pointer first:rounded-t-md last:rounded-b-md my-0.25 first:mt-0 last:mb-0 ${
|
||||
t === "default" ? "" : `theme-${t}`
|
||||
} ${t === selectedTheme ? "bg-neutral-hover" : ""}`}
|
||||
style={{
|
||||
backgroundColor:
|
||||
t === "default"
|
||||
? DEFAULT_COLORS.background
|
||||
: "var(--brand-primary-light)",
|
||||
color:
|
||||
t === "default"
|
||||
? DEFAULT_COLORS.text
|
||||
: "var(--brand-primary)",
|
||||
border:
|
||||
t === "default"
|
||||
? `1px solid ${DEFAULT_COLORS.border}`
|
||||
: "1px solid var(--brand-primary)",
|
||||
}}
|
||||
>
|
||||
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Tooltip = ({ selectedTheme }: { selectedTheme: string }) => {
|
||||
return (
|
||||
<div className="absolute bottom-full right-0 mb-2 w-64 p-3 bg-neutral-surface border border-neutral-border rounded-lg shadow-lg text-xs text-neutral-text min-w-fit">
|
||||
<div className="font-medium mb-2">Theme Preview</div>
|
||||
<p className="mb-2">
|
||||
Theme changes are temporary and will reset when you open a new browser
|
||||
window or tab.
|
||||
</p>
|
||||
<p className="mb-2">
|
||||
To make theme changes permanent, update the{" "}
|
||||
<code className="bg-neutral-hover px-1 rounded">Selected Theme</code>{" "}
|
||||
field in your Settings through TinaCMS:
|
||||
</p>
|
||||
<code className="block bg-neutral-hover p-2 rounded text-xs font-mono">
|
||||
selectedTheme={selectedTheme}
|
||||
</code>
|
||||
<div className="absolute top-full right-2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-neutral-border" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user