mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-03 00:47:53 +00:00
unprotect webapp pages and signup cta on generate (#1504)
- public viewable pages - signup cta on generate if not logged in
This commit is contained in:
1 parent
9ddf603ac8
commit
0c2dd82161
8 files changed
+183
-125
No files matched your search
@@ -31,6 +31,7 @@ import {
|
||||
} from "../components/ui/sidebar";
|
||||
import { AppSidebar } from "../components/sidebar/app-sidebar";
|
||||
import { TopBar } from "../components/topbar/topbar";
|
||||
import { SignupCtaModal } from "../components/signup-cta-modal";
|
||||
import { useSession } from "../lib/session";
|
||||
|
||||
function ScrollToTop() {
|
||||
@@ -112,19 +113,22 @@ export function App() {
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/forgot-password/verify" element={<VerifyReset />} />
|
||||
|
||||
{/* Protected — auth gate + sidebar/topbar chrome */}
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<ProtectedLayout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/create-image" element={<CreateImage />} />
|
||||
<Route path="/create-video" element={<CreateVideo />} />
|
||||
<Route path="/background-change" element={<CreateVFX />} />
|
||||
{/* Public — sidebar/topbar chrome, but no auth gate. Generate actions
|
||||
inside the create pages pop a signup CTA modal for logged-out users. */}
|
||||
<Route element={<ProtectedLayout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/create-image" element={<CreateImage />} />
|
||||
<Route path="/create-video" element={<CreateVideo />} />
|
||||
<Route path="/background-change" element={<CreateVFX />} />
|
||||
<Route path="/support" element={<Support />} />
|
||||
<Route path="/pricing" element={<Pricing />} />
|
||||
|
||||
{/* Protected — sign-in required (user-owned content / billing flows) */}
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route path="/media" element={<Media />} />
|
||||
<Route path="/media/:id" element={<Media />} />
|
||||
<Route path="/support" element={<Support />} />
|
||||
<Route path="/library" element={<Library />} />
|
||||
<Route path="/library/:filter" element={<Library />} />
|
||||
<Route path="/pricing" element={<Pricing />} />
|
||||
<Route path="/welcome" element={<Welcome />} />
|
||||
<Route path="/onboarding" element={<Onboarding />} />
|
||||
<Route path="/checkout/success" element={<CheckoutSuccess />} />
|
||||
@@ -141,11 +145,13 @@ export function App() {
|
||||
path="/portal_closed"
|
||||
element={<Navigate to="/checkout/cancel" replace />}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<ToastContainer />
|
||||
<SignupCtaModal />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+4
-55
@@ -1,11 +1,6 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faSpinnerThird,
|
||||
type IconDefinition,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { faSpinnerThird } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { type PopoverItem } from "@storyteller/ui-popover";
|
||||
import Seo from "../../components/seo";
|
||||
|
||||
@@ -13,13 +8,10 @@ interface CreateMediaPageShellProps {
|
||||
// SEO
|
||||
title: string;
|
||||
description: string;
|
||||
// Auth state
|
||||
// Auth state — `authChecked` gates the initial spinner so we don't flash
|
||||
// logged-out chrome while the session resolves. Pages stay viewable for
|
||||
// logged-out users; the signup CTA modal is triggered at generate time.
|
||||
authChecked: boolean;
|
||||
isLoggedIn: boolean;
|
||||
// Unauthenticated state
|
||||
heroIcon: IconDefinition;
|
||||
heroTitle: string;
|
||||
heroSubtitle: string;
|
||||
// Content
|
||||
hasContent: boolean;
|
||||
emptyStateTitle: string;
|
||||
@@ -40,10 +32,6 @@ export function CreateMediaPageShell({
|
||||
title,
|
||||
description,
|
||||
authChecked,
|
||||
isLoggedIn,
|
||||
heroIcon,
|
||||
heroTitle,
|
||||
heroSubtitle,
|
||||
hasContent,
|
||||
emptyStateTitle,
|
||||
emptyStateSubtitle,
|
||||
@@ -64,45 +52,6 @@ export function CreateMediaPageShell({
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
return (
|
||||
<div className="relative h-full overflow-x-hidden bg-[#101014] text-white">
|
||||
<Seo title={title} description={description} />
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-0 flex justify-center">
|
||||
<div className="h-[600px] w-[600px] rounded-full bg-gradient-to-br from-primary/30 via-blue-500/20 to-teal-400/10 opacity-40 blur-[120px]" />
|
||||
</div>
|
||||
<div className="relative z-10 flex h-full flex-col items-center justify-center px-4">
|
||||
<FontAwesomeIcon
|
||||
icon={heroIcon}
|
||||
className="mb-6 text-5xl text-white/20"
|
||||
/>
|
||||
<h1 className="mb-3 text-4xl font-semibold">{heroTitle}</h1>
|
||||
<p className="mb-8 max-w-md text-center text-lg text-white/60">
|
||||
{heroSubtitle}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Link to="/login">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="bg-white px-6 py-2.5 font-semibold text-black shadow-md hover:bg-white/90"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/signup">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="px-6 py-2.5 font-semibold shadow-md"
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full bg-[#101014] text-white">
|
||||
<Seo title={title} description={description} />
|
||||
|
||||
@@ -120,14 +120,19 @@ export function useGalleryData(options: {
|
||||
[username, filterMediaClasses, pageIndex, api, mapApiItem, excludeUploads],
|
||||
);
|
||||
|
||||
// Initial load + filter change
|
||||
// Initial load + filter change. When logged out (no username), clear the
|
||||
// loading flag so the shell renders the empty state instead of a spinner.
|
||||
useEffect(() => {
|
||||
if (!username) return;
|
||||
setItems([]);
|
||||
setPageIndex(0);
|
||||
isLoadingRef.current = false;
|
||||
if (!username) {
|
||||
setHasMore(false);
|
||||
setIsInitialLoading(false);
|
||||
return;
|
||||
}
|
||||
setHasMore(true);
|
||||
setIsInitialLoading(true);
|
||||
isLoadingRef.current = false;
|
||||
loadItems(true);
|
||||
}, [username, JSON.stringify(filterMediaClasses)]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import { create } from "zustand";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faCheck } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Modal } from "@storyteller/ui-modal";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { useSession } from "../lib/session";
|
||||
|
||||
interface SignupCtaState {
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const useSignupCtaStore = create<SignupCtaState>((set) => ({
|
||||
isOpen: false,
|
||||
open: () => set({ isOpen: true }),
|
||||
close: () => set({ isOpen: false }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Hook that exposes the logged-in flag plus an imperative `openSignupCta()`
|
||||
* trigger. Use it at the top of a generate handler:
|
||||
*
|
||||
* const { loggedIn, openSignupCta } = useSignupCta();
|
||||
* if (!loggedIn) { openSignupCta(); return; }
|
||||
*/
|
||||
export function useSignupCta(): {
|
||||
loggedIn: boolean;
|
||||
openSignupCta: () => void;
|
||||
} {
|
||||
const { loggedIn } = useSession();
|
||||
const openSignupCta = useSignupCtaStore((s) => s.open);
|
||||
return { loggedIn, openSignupCta };
|
||||
}
|
||||
|
||||
const PERKS: string[] = [
|
||||
"Generate images and videos with top AI models",
|
||||
"Save your work and access it from any device",
|
||||
];
|
||||
|
||||
export function SignupCtaModal() {
|
||||
const isOpen = useSignupCtaStore((s) => s.isOpen);
|
||||
const close = useSignupCtaStore((s) => s.close);
|
||||
const location = useLocation();
|
||||
const from = encodeURIComponent(location.pathname + location.search);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
className="rounded-2xl w-full max-w-md overflow-hidden border border-white/10 bg-[#161618] p-0 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.6)]"
|
||||
allowBackgroundInteraction={false}
|
||||
showClose={true}
|
||||
closeOnOutsideClick={true}
|
||||
resizable={false}
|
||||
childPadding={false}
|
||||
backdropClassName="bg-black/80"
|
||||
>
|
||||
<div className="relative overflow-hidden">
|
||||
{/* Off-center ambient glow — feels designed, not generic */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -top-24 -right-16 h-64 w-64 rounded-full bg-primary/25 blur-[80px]"
|
||||
/>
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/15 to-transparent"
|
||||
/>
|
||||
|
||||
<div className="relative px-8 pt-10 pb-8 sm:px-10 sm:pt-12 sm:pb-10">
|
||||
<h2 className="mt-3 font-display text-3xl font-semibold tracking-tight text-white sm:text-[34px] sm:leading-[1.1]">
|
||||
Start <span className="text-primary">crafting</span> in seconds.
|
||||
</h2>
|
||||
<p className="mt-3 max-w-[20rem] text-[15px] leading-relaxed text-white/55">
|
||||
Free to try. Sign up and pick up where you left off.
|
||||
</p>
|
||||
|
||||
<ul className="mt-7 space-y-3">
|
||||
{PERKS.map((perk) => (
|
||||
<li
|
||||
key={perk}
|
||||
className="flex items-start gap-3 text-[14px] text-white/75"
|
||||
>
|
||||
<span className="mt-[2px] flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-primary/15">
|
||||
<FontAwesomeIcon
|
||||
icon={faCheck}
|
||||
className="text-[9px] text-primary"
|
||||
/>
|
||||
</span>
|
||||
<span>{perk}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="mt-8 flex flex-col gap-2">
|
||||
<Link to={`/signup?from=${from}`} onClick={close} className="block">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full h-12 text-sm font-semibold rounded-full"
|
||||
>
|
||||
Create free account
|
||||
</Button>
|
||||
</Link>
|
||||
<Link
|
||||
to={`/login?from=${from}`}
|
||||
onClick={close}
|
||||
className="text-center text-[13px] text-white/55 hover:text-white transition-colors py-2"
|
||||
>
|
||||
Already have an account?{" "}
|
||||
<span className="font-medium text-white/90">Log in</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { faImage } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { FilterMediaClasses } from "@storyteller/api";
|
||||
import type { OmniGenImageModelInfo } from "@storyteller/api";
|
||||
import { PopoverMenu, type PopoverItem } from "@storyteller/ui-popover";
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
useOmniGenImageModels,
|
||||
getModelCreatorIconPath,
|
||||
} from "../../lib/omni-gen-hooks";
|
||||
import { useSignupCta } from "../../components/signup-cta-modal";
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -63,6 +63,7 @@ function buildModelPopoverItems(
|
||||
|
||||
export default function CreateImage() {
|
||||
const { user, authChecked } = useAuthCheck();
|
||||
const { loggedIn, openSignupCta } = useSignupCta();
|
||||
const { promptBoxRef, promptHeight } = usePromptHeight();
|
||||
|
||||
// Fetch models from API
|
||||
@@ -277,6 +278,10 @@ export default function CreateImage() {
|
||||
);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!loggedIn) {
|
||||
openSignupCta();
|
||||
return;
|
||||
}
|
||||
if (!prompt.trim() || isGenerating || !selectedModel) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
@@ -335,6 +340,8 @@ export default function CreateImage() {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
}, [
|
||||
loggedIn,
|
||||
openSignupCta,
|
||||
prompt,
|
||||
isGenerating,
|
||||
selectedModel,
|
||||
@@ -358,10 +365,6 @@ export default function CreateImage() {
|
||||
title="Create Image - ArtCraft"
|
||||
description="Generate stunning AI images with ArtCraft"
|
||||
authChecked={authChecked}
|
||||
isLoggedIn={!!user}
|
||||
heroIcon={faImage}
|
||||
heroTitle="Create Image"
|
||||
heroSubtitle="Sign in to generate stunning AI images with multiple models"
|
||||
hasContent={hasContent}
|
||||
emptyStateTitle="Generate Image"
|
||||
emptyStateSubtitle="Add a prompt, then generate"
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faSpinnerThird, faSparkles } from "@fortawesome/pro-solid-svg-icons";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import {
|
||||
PromptBoxVFX,
|
||||
VFXResultCard,
|
||||
@@ -10,6 +8,7 @@ import {
|
||||
} from "@storyteller/ui-vfx";
|
||||
import Seo from "../../components/seo";
|
||||
import { useAuthCheck } from "../../components/generation-gallery";
|
||||
import { useSignupCta } from "../../components/signup-cta-modal";
|
||||
import { toast } from "../../components/toast/toast";
|
||||
import { uploadImage } from "../../components/prompt-box/upload-image";
|
||||
import { uploadVideo } from "../../components/prompt-box/upload-media";
|
||||
@@ -21,6 +20,7 @@ import {
|
||||
|
||||
export default function CreateVFX() {
|
||||
const { user, authChecked } = useAuthCheck();
|
||||
const { loggedIn, openSignupCta } = useSignupCta();
|
||||
|
||||
const history = useVFXStore((s) => s.history);
|
||||
const startResult = useVFXStore((s) => s.startResult);
|
||||
@@ -190,6 +190,10 @@ export default function CreateVFX() {
|
||||
}, [user, completeResult, failResult, seedFromSession, updateMediaForResult]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!loggedIn) {
|
||||
openSignupCta();
|
||||
return;
|
||||
}
|
||||
if (!source || !reference || isSubmitting) return;
|
||||
setIsSubmitting(true);
|
||||
const id = startResult();
|
||||
@@ -230,6 +234,8 @@ export default function CreateVFX() {
|
||||
);
|
||||
pollersRef.current.set(id, cancel);
|
||||
}, [
|
||||
loggedIn,
|
||||
openSignupCta,
|
||||
source,
|
||||
reference,
|
||||
prompt,
|
||||
@@ -251,48 +257,6 @@ export default function CreateVFX() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="relative min-h-screen overflow-x-hidden bg-[#101014] text-white">
|
||||
<Seo
|
||||
title="Background Change - ArtCraft"
|
||||
description="Swap the backdrop of a video using a reference image."
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-0 flex justify-center">
|
||||
<div className="h-[600px] w-[600px] rounded-full bg-gradient-to-br from-primary/30 via-blue-500/20 to-teal-400/10 opacity-40 blur-[120px]" />
|
||||
</div>
|
||||
<div className="relative z-10 flex min-h-screen flex-col items-center justify-center px-4">
|
||||
<FontAwesomeIcon
|
||||
icon={faSparkles}
|
||||
className="mb-6 text-5xl text-white/20"
|
||||
/>
|
||||
<h1 className="mb-3 text-4xl font-bold">Background Change</h1>
|
||||
<p className="mb-8 max-w-md text-center text-lg text-white/60">
|
||||
Sign in to swap the backdrop of a video using a reference image.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Link to="/login">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="bg-white px-6 py-2.5 font-semibold text-black shadow-md hover:bg-white/90"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to="/signup">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="px-6 py-2.5 font-semibold shadow-md"
|
||||
>
|
||||
Sign up
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full bg-[#101014] text-white">
|
||||
<Seo
|
||||
|
||||
@@ -3,7 +3,6 @@ import { flushSync } from "react-dom";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import {
|
||||
faClock,
|
||||
faFilm,
|
||||
faWaveformLines,
|
||||
} from "@fortawesome/pro-solid-svg-icons";
|
||||
import { CharactersApi, FilterMediaClasses } from "@storyteller/api";
|
||||
@@ -48,6 +47,7 @@ import {
|
||||
useOmniGenVideoModels,
|
||||
getModelCreatorIconPath,
|
||||
} from "../../lib/omni-gen-hooks";
|
||||
import { useSignupCta } from "../../components/signup-cta-modal";
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -162,6 +162,7 @@ function resolveDurationForModel(
|
||||
|
||||
export default function CreateVideo() {
|
||||
const { user, authChecked } = useAuthCheck();
|
||||
const { loggedIn, openSignupCta } = useSignupCta();
|
||||
const { promptBoxRef, promptHeight } = usePromptHeight();
|
||||
|
||||
// Fetch models from API
|
||||
@@ -704,6 +705,10 @@ export default function CreateVideo() {
|
||||
}, []);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
if (!loggedIn) {
|
||||
openSignupCta();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!prompt.trim() ||
|
||||
isGeneratingRef.current ||
|
||||
@@ -849,6 +854,8 @@ export default function CreateVideo() {
|
||||
setIsGenerating(false);
|
||||
isGeneratingRef.current = false;
|
||||
}, [
|
||||
loggedIn,
|
||||
openSignupCta,
|
||||
prompt,
|
||||
needsImage,
|
||||
isReferenceMode,
|
||||
@@ -888,10 +895,6 @@ export default function CreateVideo() {
|
||||
title="Create Video - ArtCraft"
|
||||
description="Generate stunning AI videos with ArtCraft"
|
||||
authChecked={authChecked}
|
||||
isLoggedIn={!!user}
|
||||
heroIcon={faFilm}
|
||||
heroTitle="Create Video"
|
||||
heroSubtitle="Sign in to generate stunning AI videos with multiple models"
|
||||
hasContent={hasContent}
|
||||
emptyStateTitle="Generate Video"
|
||||
emptyStateSubtitle="Add a prompt, then generate"
|
||||
|
||||
@@ -14,6 +14,8 @@ type AppCard = {
|
||||
href: string;
|
||||
icon: IconDefinition;
|
||||
accent: string;
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
};
|
||||
|
||||
const APPS: AppCard[] = [
|
||||
@@ -23,6 +25,8 @@ const APPS: AppCard[] = [
|
||||
href: "/create-image",
|
||||
icon: faImage,
|
||||
accent: "from-blue-500/20 to-blue-500/0",
|
||||
iconBg: "bg-blue-500/25 border-blue-400/30",
|
||||
iconColor: "text-blue-300",
|
||||
},
|
||||
{
|
||||
label: "Video",
|
||||
@@ -30,6 +34,8 @@ const APPS: AppCard[] = [
|
||||
href: "/create-video",
|
||||
icon: faVideo,
|
||||
accent: "from-purple-500/20 to-purple-500/0",
|
||||
iconBg: "bg-purple-500/25 border-purple-400/30",
|
||||
iconColor: "text-purple-300",
|
||||
},
|
||||
{
|
||||
label: "Background Change",
|
||||
@@ -37,6 +43,8 @@ const APPS: AppCard[] = [
|
||||
href: "/background-change",
|
||||
icon: faWandMagicSparkles,
|
||||
accent: "from-emerald-500/20 to-emerald-500/0",
|
||||
iconBg: "bg-emerald-500/25 border-emerald-400/30",
|
||||
iconColor: "text-emerald-300",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -54,14 +62,16 @@ export function Home() {
|
||||
<Link
|
||||
key={app.href}
|
||||
to={app.href}
|
||||
className="group relative overflow-hidden rounded-2xl border border-white/[0.06] bg-white/[0.02] hover:bg-white/[0.04] hover:border-white/[0.12] transition-all p-5"
|
||||
className="bg-ui-controls/50 group relative overflow-hidden rounded-2xl p-5 hover:border-white/20 hover:bg-ui-controls"
|
||||
>
|
||||
<div
|
||||
className={`absolute -inset-px rounded-2xl bg-gradient-to-br ${app.accent} opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none`}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="relative flex items-start gap-4">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-white/[0.06] border border-white/[0.06] text-white/85">
|
||||
<div
|
||||
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border ${app.iconBg} ${app.iconColor}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={app.icon} className="text-base" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -71,7 +81,7 @@ export function Home() {
|
||||
</h3>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowRight}
|
||||
className="text-[11px] text-white/40 group-hover:text-white/70 group-hover:translate-x-0.5 transition-all"
|
||||
className="text-sm text-white/40 group-hover:text-white/70 group-hover:translate-x-0.5 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-white/55 leading-snug">
|
||||
|
||||
Reference in new issue
Block a user