Improve Explore Apps page
Some checks failed
all-good: Did all the other checks pass? / all-good (push) Has been cancelled
Ensure Prisma migrations are in sync with the schema / check_prisma_migrations (22.x) (push) Has been cancelled
Docker Server Build and Push / Docker Build and Push Server (push) Has been cancelled
Docker Server Test / docker (push) Has been cancelled
Runs E2E API Tests / build (22.x) (push) Has been cancelled
Runs E2E API Tests with custom port prefix / build (22.x) (push) Has been cancelled
Runs E2E API Tests with external source of truth / build (22.x) (push) Has been cancelled
Lint & build / lint_and_build (latest) (push) Has been cancelled
Dev Environment Test With Custom Base Port / restart-dev-and-test-with-custom-base-port (push) Has been cancelled
Dev Environment Test / restart-dev-and-test (push) Has been cancelled
Run setup tests / setup-tests (push) Has been cancelled
TOC Generator / TOC Generator (push) Has been cancelled
Mirror main branch to main-mirror-for-wdb / lint_and_build (push) Has been cancelled
Sync Main to Dev / sync-commits (push) Has been cancelled

This commit is contained in:
Konstantin Wohlwend 2025-12-04 12:25:06 -08:00
parent 2796f93e2b
commit 92ef462d13
6 changed files with 118 additions and 94 deletions

View File

@ -13,24 +13,41 @@ export default function AppDetailsPageClient({ appId }: { appId: AppId }) {
const adminApp = useAdminApp()!;
const project = adminApp.useProject();
const config = project.useConfig();
const isEnabled = config.apps.installed[appId]?.enabled ?? false;
const appFrontend = ALL_APPS_FRONTEND[appId];
if (!(appFrontend as any)) {
throw new StackAssertionError(`App frontend not found for appId: ${appId}`, { appId });
}
const appPath = getAppPath(project.id, appFrontend);
const handleEnable = async () => {
await project.updateConfig({
[`apps.installed.${appId}.enabled`]: true,
});
const appFrontend = ALL_APPS_FRONTEND[appId];
if (!(appFrontend as any)) {
throw new StackAssertionError(`App frontend not found for appId: ${appId}`, { appId });
}
const path = getAppPath(project.id, appFrontend);
router.push(path);
router.push(appPath);
};
const handleOpen = () => {
router.push(appPath);
};
const handleDisable = async () => {
await project.updateConfig({
[`apps.installed.${appId}.enabled`]: false,
});
};
return (
<PageLayout fillWidth>
<AppStoreEntry
appId={appId}
isEnabled={isEnabled}
onEnable={async () => runAsynchronouslyWithAlert(handleEnable())}
onOpen={handleOpen}
onDisable={async () => runAsynchronouslyWithAlert(handleDisable())}
/>
</PageLayout>
);

View File

@ -188,7 +188,7 @@ export default function PageClient() {
{/* Apps Grid */}
{filteredApps.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
<div className="grid gap-4" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))" }}>
{filteredApps.map(appId => (
<AppSquare
key={appId}

View File

@ -1,9 +1,9 @@
import { useAdminApp, useProjectId } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
import { ALL_APPS_FRONTEND, AppFrontend, getAppPath } from "@/lib/apps-frontend";
import { ALL_APPS, AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
import { AppIcon as SharedAppIcon, appSquarePaddingExpression, appSquareWidthExpression } from "@stackframe/stack-shared/dist/apps/apps-ui";
import { Button, cn } from "@stackframe/stack-ui";
import { Check } from "lucide-react";
import { appSquarePaddingExpression, appSquareWidthExpression, AppIcon as SharedAppIcon } from "@stackframe/stack-shared/dist/apps/apps-ui";
import { Button, cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@stackframe/stack-ui";
import { Check, MoreVertical } from "lucide-react";
import { useState } from "react";
import { AppWarningModal } from "./app-warning-modal";
import { Link } from "./link";
@ -49,9 +49,6 @@ export function AppSquare({
onToggleEnabled?: (enabled: boolean) => void,
}) {
const app = ALL_APPS[appId];
const appFrontend = ALL_APPS_FRONTEND[appId];
const [isHovered, setIsHovered] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const [showWarningModal, setShowWarningModal] = useState(false);
const projectId = useProjectId();
@ -62,12 +59,7 @@ export function AppSquare({
const isEnabled = config.apps.installed[appId]?.enabled ?? false;
const appDetailsPath = `/projects/${projectId}/apps/${appId}`;
const handleToggleEnabled = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (isProcessing) return;
const handleToggleEnabled = async () => {
// Show warning modal for alpha/beta apps when enabling
if (!isEnabled && app.stage !== "stable") {
setShowWarningModal(true);
@ -79,46 +71,38 @@ export function AppSquare({
};
const performToggle = async () => {
setIsProcessing(true);
try {
await project.updateConfig({
[`apps.installed.${appId}.enabled`]: !isEnabled,
});
onToggleEnabled?.(!isEnabled);
} finally {
setIsProcessing(false);
}
await project.updateConfig({
[`apps.installed.${appId}.enabled`]: !isEnabled,
});
onToggleEnabled?.(!isEnabled);
};
return (
<>
<div
className={cn(
"group relative aspect-square",
isProcessing && "pointer-events-none opacity-50"
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="group relative">
<Link
href={appDetailsPath}
className={cn(
"absolute inset-0 flex flex-col items-center p-3 sm:p-4 rounded-xl sm:rounded-2xl transition-all duration-200 hover:transition-none cursor-pointer overflow-hidden",
"flex flex-col items-center p-3 sm:p-4 rounded-xl sm:rounded-2xl cursor-pointer",
"bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800",
"hover:border-gray-300 dark:hover:border-gray-700 hover:shadow-lg",
isEnabled && "border-green-500/40 dark:border-green-500/30 bg-green-50/30 dark:bg-green-950/20"
// Hover/active states - button-like feel
"hover:bg-gray-100 dark:hover:bg-gray-800 hover:border-gray-300 dark:hover:border-gray-600",
"active:scale-[0.98] active:bg-gray-150 dark:active:bg-gray-750",
// Transitions only on hover-out
"transition-[transform,background-color,border-color,box-shadow] duration-150 hover:transition-none",
isEnabled && "border-green-500/40 dark:border-green-500/30 bg-green-50/30 dark:bg-green-950/20 hover:bg-green-100/50 dark:hover:bg-green-900/30"
)}
>
{/* Icon container - fixed height portion */}
<div className="flex items-center justify-center w-full h-[45%]">
{/* Icon container */}
<div className="flex items-center justify-center w-full mb-3">
<AppIcon
appId={appId}
variant={isEnabled ? "installed" : variant}
/>
</div>
{/* Text container - fixed height portion */}
<div className="w-full h-[55%] flex flex-col items-center justify-start pt-2 overflow-hidden">
{/* Text container */}
<div className="w-full flex flex-col items-center">
<span className={cn(
"text-[11px] sm:text-xs font-medium text-center w-full line-clamp-1",
"text-gray-900 dark:text-gray-100"
@ -134,38 +118,39 @@ export function AppSquare({
</div>
</Link>
{/* Hover actions */}
<div className={cn(
"absolute inset-x-0 bottom-3 sm:bottom-4 flex justify-center gap-2 transition-all duration-200 z-10",
(isHovered || isProcessing) ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2 pointer-events-none"
)}>
<Button
onClick={handleToggleEnabled}
loading={isProcessing}
variant="plain"
size="plain"
className={cn(
"px-3 sm:px-4 py-1 sm:py-1.5 text-[10px] sm:text-xs font-medium rounded-full transition-all h-auto min-h-0",
"shadow-lg backdrop-blur-sm",
isProcessing && "px-2 sm:px-3 py-1 text-[9px] sm:text-[10px]",
isEnabled
? "bg-gray-900/90 dark:bg-gray-100/90 text-white dark:text-gray-900 hover:bg-gray-800 dark:hover:bg-gray-200"
: "bg-blue-600/90 text-white hover:bg-blue-700"
)}
>
{isEnabled ? 'Disable' : 'Enable'}
</Button>
{/* Three-dot menu in top-right corner */}
<div className="absolute top-1.5 right-1.5 sm:top-2 sm:right-2 z-10">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
"p-1 rounded-md opacity-0 group-hover:opacity-100 transition-opacity hover:transition-none",
"hover:bg-gray-200 dark:hover:bg-gray-700",
"focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
)}
onClick={(e) => e.preventDefault()}
>
<MoreVertical className="w-4 h-4 text-gray-500 dark:text-gray-400" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[120px]">
<DropdownMenuItem onClick={handleToggleEnabled} className="cursor-pointer">
{isEnabled ? 'Disable' : 'Enable'}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Status badges in top-right corner */}
{/* Status badge - enabled checkmark */}
{isEnabled && (
<div className="absolute top-2 right-2 sm:top-3 sm:right-3 w-5 h-5 sm:w-6 sm:h-6 bg-green-500 rounded-full flex items-center justify-center shadow-md z-10">
<div className="absolute top-2 right-8 sm:top-3 sm:right-9 w-5 h-5 sm:w-6 sm:h-6 bg-green-500 rounded-full flex items-center justify-center shadow-md z-10">
<Check className="w-3 h-3 sm:w-3.5 sm:h-3.5 text-white" strokeWidth={3} />
</div>
)}
{/* Status badge - alpha/beta */}
{!isEnabled && app.stage !== "stable" && (
<div className="absolute top-2 right-2 sm:top-3 sm:right-3 z-10">
<div className="absolute top-2 right-8 sm:top-3 sm:right-9 z-10">
<div className={cn(
"px-1.5 sm:px-2 py-0.5 rounded sm:rounded-md text-[8px] sm:text-[10px] font-bold uppercase tracking-wide border",
app.stage === "alpha"

View File

@ -4,17 +4,23 @@ import { AppIcon } from "@/components/app-square";
import { ALL_APPS_FRONTEND, type AppId } from "@/lib/apps-frontend";
import { ALL_APPS, ALL_APP_TAGS } from "@stackframe/stack-shared/dist/apps/apps-config";
import { Badge, Button, Dialog, DialogContent, DialogTitle, ScrollArea, cn } from "@stackframe/stack-ui";
import { Check, ChevronLeft, ChevronRight, Info, Shield, X, Zap } from "lucide-react";
import { Check, ChevronLeft, ChevronRight, ExternalLink, Shield, X, Zap } from "lucide-react";
import Image from "next/image";
import { FunctionComponent, useCallback, useEffect, useRef, useState } from "react";
export function AppStoreEntry({
appId,
isEnabled = false,
onEnable,
onOpen,
onDisable,
titleComponent: TitleComponent = "h1",
}: {
appId: AppId,
isEnabled?: boolean,
onEnable: () => Promise<void>,
onOpen?: () => void,
onDisable?: () => Promise<void>,
titleComponent?: FunctionComponent<any> | string,
}) {
const app = ALL_APPS[appId];
@ -69,9 +75,10 @@ export function AppStoreEntry({
];
return (
<div className="flex flex-col h-full bg-gradient-to-b from-gray-50 to-white dark:from-gray-900 dark:to-gray-950">
{/* Hero Section */}
<div className="relative px-6 py-8 border-b border-gray-200 dark:border-gray-800">
<ScrollArea className="h-full">
<div className="flex flex-col bg-gradient-to-b from-gray-50 to-white dark:from-gray-900 dark:to-gray-950">
{/* Hero Section */}
<div className="relative px-6 py-8 border-b border-gray-200 dark:border-gray-800">
<div className="max-w-4xl mx-auto">
<div className="flex flex-col md:flex-row gap-6 items-start">
{/* App Icon */}
@ -136,17 +143,35 @@ export function AppStoreEntry({
{/* CTA Button */}
<div className="flex items-center gap-4">
<Button
onClick={onEnable}
size="lg"
className="px-8 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium shadow-lg shadow-blue-500/20"
>
Enable App
</Button>
<div className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<Info className="w-4 h-4" />
<span>No additional cost</span>
</div>
{isEnabled ? (
<>
<Button
onClick={onOpen}
size="lg"
className="px-8 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium shadow-lg shadow-blue-500/20"
>
<ExternalLink className="w-4 h-4 mr-2" />
Open App
</Button>
{onDisable && (
<Button
onClick={onDisable}
variant="secondary"
size="sm"
>
Disable
</Button>
)}
</>
) : (
<Button
onClick={onEnable}
size="lg"
className="px-8 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium shadow-lg shadow-blue-500/20"
>
Enable App
</Button>
)}
</div>
</div>
</div>
@ -239,8 +264,7 @@ export function AppStoreEntry({
</div>
)}
{/* Description Section */}
<ScrollArea className="flex-1">
{/* Description Section */}
<div className="max-w-4xl mx-auto px-6 py-8">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4">
About This App
@ -249,7 +273,6 @@ export function AppStoreEntry({
{appFrontend.storeDescription}
</div>
</div>
</ScrollArea>
{/* Screenshot Preview Modal */}
<Dialog open={previewIndex !== null} onOpenChange={(open) => !open && setPreviewIndex(null)}>
@ -313,6 +336,7 @@ export function AppStoreEntry({
</div>
</DialogContent>
</Dialog>
</div>
</div>
</ScrollArea>
);
}

View File

@ -234,14 +234,12 @@ const AvailableAppPreview = memo(function AvailableAppPreview({
)}
{/* Description */}
{appFrontend.storeDescription && (
<div>
<h4 className="text-xs font-medium text-foreground mb-2">About</h4>
<div className="text-xs text-muted-foreground prose prose-sm dark:prose-invert max-w-none">
{appFrontend.storeDescription}
</div>
<div>
<h4 className="text-xs font-medium text-foreground mb-2">About</h4>
<div className="text-xs text-muted-foreground prose prose-sm dark:prose-invert max-w-none">
{appFrontend.storeDescription}
</div>
)}
</div>
</div>
</ScrollArea>
</div>

View File

@ -37,7 +37,7 @@ export type AppFrontend = {
getBreadcrumbItems?: (stackAdminApp: StackAdminApp<false>, relativePart: string) => Promise<BreadcrumbDefinition | null | undefined>,
navigationItems: AppNavigationItem[],
screenshots: (string | StaticImageData)[],
storeDescription: React.ReactNode,
storeDescription: JSX.Element,
};
export function getAppPath(projectId: string, appFrontend: AppFrontend) {