mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
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
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:
parent
2796f93e2b
commit
92ef462d13
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user