diff --git a/ui/webapp/src/data.ts b/ui/webapp/src/data.ts index cc7b5e22..88a114e8 100644 --- a/ui/webapp/src/data.ts +++ b/ui/webapp/src/data.ts @@ -36,6 +36,7 @@ export const VIEW_MODE_PARAM = 'view-mode'; export const GROUP_PARAM = 'group'; export const MODAL_PARAM = 'modal'; export const ITEM_PARAM = 'item'; +export const VIEW_PARAM = 'view'; export const CATEGORY_PARAM = 'category'; export const SUBCATEGORY_PARAM = 'subcategory'; export const PAGE_PARAM = 'page'; diff --git a/ui/webapp/src/layout/index.tsx b/ui/webapp/src/layout/index.tsx index 81d01896..cfd4757c 100644 --- a/ui/webapp/src/layout/index.tsx +++ b/ui/webapp/src/layout/index.tsx @@ -1,14 +1,17 @@ +import { useSearchParams } from '@solidjs/router'; import isEmpty from 'lodash/isEmpty'; -import { createSignal, JSXElement, onMount } from 'solid-js'; +import { createSignal, JSXElement, onMount, Show } from 'solid-js'; +import { ITEM_PARAM, VIEW_PARAM } from '../data'; import ItemModal from './common/itemModal'; import ZoomModal from './common/zoomModal'; +import KioskView from './kiosk'; import styles from './Layout.module.css'; import Header from './navigation/Header'; import MobileHeader from './navigation/MobileHeader'; import { ActiveItemProvider } from './stores/activeItem'; import { FinancesDataProvider } from './stores/financesData'; -import { FullDataProvider } from './stores/fullData'; +import { FullDataProvider, useFullDataReady } from './stores/fullData'; import { GridWidthProvider } from './stores/gridWidth'; import { GroupActiveProvider } from './stores/groupActive'; import { GuideFileProvider } from './stores/guideFile'; @@ -23,9 +26,23 @@ interface Props { children?: JSXElement; } +// Inner wrapper so useFullDataReady() is called inside FullDataProvider +const KioskWrapper = (props: { itemId: string }) => { + const fullDataReady = useFullDataReady(); + return ; +}; + const Layout = (props: Props) => { + const [searchParams] = useSearchParams(); const [statsVisible, setStatsVisible] = createSignal(true); + const isKioskView = () => searchParams[VIEW_PARAM] === 'kiosk' && !!searchParams[ITEM_PARAM]; + const kioskItemId = (): string => { + const val = searchParams[ITEM_PARAM]; + if (Array.isArray(val)) return val[0] || ''; + return val || ''; + }; + onMount(() => { // Check if statsDS is empty, if so, hide the stats link if (isEmpty(window.statsDS)) { @@ -35,34 +52,36 @@ const Layout = (props: Props) => { return ( - - - - - - - - - - -
- -
-
{props.children}
-
- - - -
-
-
-
-
-
-
-
-
-
+ }> + + + + + + + + + + +
+ +
+
{props.children}
+
+ + + +
+
+
+
+
+
+
+
+
+
+
); }; diff --git a/ui/webapp/src/layout/kiosk/KioskView.module.css b/ui/webapp/src/layout/kiosk/KioskView.module.css new file mode 100644 index 00000000..63155d61 --- /dev/null +++ b/ui/webapp/src/layout/kiosk/KioskView.module.css @@ -0,0 +1,234 @@ +.kioskContainer { + min-height: 100vh; + background-color: #f5f7fa; + padding: 2rem; + overflow-y: auto; +} + +.header { + text-align: center; + margin-bottom: 2rem; +} + +.logoWrapper { + width: 120px; + height: 120px; + margin: 0 auto; + display: flex; + align-items: center; + justify-content: center; +} + +.logo { + max-width: 100%; + max-height: 100%; + height: auto; +} + +.projectName { + font-size: 2rem; + font-weight: bold; +} + +.description { + font-size: 1.1rem; + color: #6c757d; + max-width: 700px; + margin: 0 auto; + line-height: 1.6; +} + +.statsBar { + display: flex; + flex-direction: row; + justify-content: center; + gap: 2rem; + background-color: #fff; + border-radius: 12px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + padding: 1rem 2rem; + margin-bottom: 2rem; +} + +.statItem { + text-align: center; + min-width: 80px; +} + +.statValue { + font-size: 1.5rem; + font-weight: bold; + color: #212529; +} + +.statLabel { + font-size: 0.8rem; + text-transform: uppercase; + color: #6c757d; +} + +.cardsGrid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1.5rem; + max-width: 1200px; + margin: 0 auto; +} + +.actionCard { + background-color: #fff; + border-radius: 12px; + padding: 1.5rem; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); + transition: box-shadow 0.2s ease; +} + +.actionCard:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.cardTitle { + font-size: 1.1rem; + font-weight: 600; + margin-bottom: 1rem; +} + +.cardLink { + display: flex; + flex-direction: row; + align-items: center; + padding: 0.6rem; + border-radius: 8px; + transition: background-color 0.15s ease; + text-decoration: none; + color: inherit; +} + +.cardLink:hover { + background-color: #f0f4f8; +} + +.cardLinkIcon { + width: 20px; + height: 20px; + margin-right: 0.75rem; + flex-shrink: 0; +} + +.badges { + display: flex; + justify-content: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.notFound { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 60vh; + text-align: center; +} + +.notFoundTitle { + font-size: 1.8rem; + font-weight: bold; + color: #212529; + margin-bottom: 0.75rem; +} + +.notFoundMessage { + font-size: 1.1rem; + color: #6c757d; + margin-bottom: 2rem; +} + +.poweredBy { + text-align: center; + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid #dee2e6; + color: #6c757d; + font-size: 0.9rem; +} + +@media (prefers-color-scheme: dark) { + .kioskContainer { + background-color: #1a1b1e; + color: #e1e3e6; + } + + .projectName { + color: #e1e3e6; + } + + .description { + color: #9ca3af; + } + + .statsBar { + background-color: #25262b; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + } + + .statValue { + color: #e1e3e6; + } + + .statLabel { + color: #9ca3af; + } + + .actionCard { + background-color: #25262b; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + } + + .actionCard:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); + } + + .cardLink { + color: #e1e3e6; + } + + .cardLink:hover { + background-color: #2c2d32; + } + + .cardTitle { + color: #e1e3e6; + } + + .notFoundTitle { + color: #e1e3e6; + } + + .notFoundMessage { + color: #9ca3af; + } + + .poweredBy { + border-top-color: #373a40; + color: #9ca3af; + } + + .poweredBy a { + color: #7cb3f0; + } +} + +@media (max-width: 768px) { + .kioskContainer { + padding: 1rem; + } + + .cardsGrid { + grid-template-columns: 1fr; + } + + .cardLink { + padding: 0.8rem; + } +} diff --git a/ui/webapp/src/layout/kiosk/index.tsx b/ui/webapp/src/layout/kiosk/index.tsx new file mode 100644 index 00000000..325b208f --- /dev/null +++ b/ui/webapp/src/layout/kiosk/index.tsx @@ -0,0 +1,342 @@ +import { + ExternalLink, + FoundationBadge, + getItemDescription, + Image, + Loading, + MaturityBadge, + prettifyNumber, + SVGIcon, + SVGIconKind, +} from 'common'; +import isUndefined from 'lodash/isUndefined'; +import { createEffect, createSignal, For, Match, Show, Switch } from 'solid-js'; + +import { FOUNDATION } from '../../data'; +import { GithubRepository, Item, Repository } from '../../types'; +import itemsDataGetter from '../../utils/itemsDataGetter'; +import styles from './KioskView.module.css'; + +interface Props { + itemId: string; + fullDataReady: boolean; +} + +interface LinkEntry { + label: string; + url: string; + icon: SVGIconKind; +} + +const KioskView = (props: Props) => { + // undefined = still loading, null = not found, Item = found + const [itemInfo, setItemInfo] = createSignal(undefined); + const [primaryRepo, setPrimaryRepo] = createSignal(undefined); + + createEffect(() => { + if (props.itemId && props.fullDataReady) { + const item = itemsDataGetter.getItemById(props.itemId); + setItemInfo(item || null); + + if (!isUndefined(item) && !isUndefined(item.repositories)) { + const primary = item.repositories.find((r: Repository) => r.primary); + setPrimaryRepo(primary); + } + } + }); + + const githubData = (): GithubRepository | undefined => { + const repo = primaryRepo(); + if (!isUndefined(repo)) { + return repo.github_data; + } + return undefined; + }; + + const goodFirstIssuesUrl = (): string | undefined => { + const repo = primaryRepo(); + if (!isUndefined(repo)) { + return `${repo.url}/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22`; + } + return undefined; + }; + + const gitjobsUrl = (): string | undefined => { + const item = itemInfo(); + if (!isUndefined(item) && item !== null) { + return `https://gitjobs.dev/jobs?query=${encodeURIComponent(item.name)}`; + } + return undefined; + }; + + const description = (): string => { + const item = itemInfo(); + if (!isUndefined(item) && item !== null) { + return getItemDescription(item); + } + return ''; + }; + + const getLinkedInUrl = (): string | undefined => { + const item = itemInfo(); + if (!isUndefined(item) && item !== null) { + if (!isUndefined(item.linkedin_url)) { + return item.linkedin_url; + } + if (!isUndefined(item.crunchbase_data) && !isUndefined(item.crunchbase_data.linkedin_url)) { + return item.crunchbase_data.linkedin_url; + } + } + return undefined; + }; + + const communityLinks = (): LinkEntry[] => { + const item = itemInfo(); + if (isUndefined(item) || item === null) return []; + + const links: LinkEntry[] = []; + if (!isUndefined(item.slack_url)) { + links.push({ label: 'Slack', url: item.slack_url, icon: SVGIconKind.Slack }); + } + if (!isUndefined(item.discord_url)) { + links.push({ label: 'Discord', url: item.discord_url, icon: SVGIconKind.Discord }); + } + if (!isUndefined(item.mailing_list_url)) { + links.push({ label: 'Mailing List', url: item.mailing_list_url, icon: SVGIconKind.MailingList }); + } + if (!isUndefined(item.github_discussions_url)) { + links.push({ label: 'GitHub Discussions', url: item.github_discussions_url, icon: SVGIconKind.Discussions }); + } + if (!isUndefined(item.stack_overflow_url)) { + links.push({ label: 'Stack Overflow', url: item.stack_overflow_url, icon: SVGIconKind.StackOverflow }); + } + return links; + }; + + const socialLinks = (): LinkEntry[] => { + const item = itemInfo(); + if (isUndefined(item) || item === null) return []; + + const links: LinkEntry[] = []; + if (!isUndefined(item.twitter_url)) { + links.push({ label: 'Twitter / X', url: item.twitter_url, icon: SVGIconKind.Twitter }); + } + if (!isUndefined(item.youtube_url)) { + links.push({ label: 'YouTube', url: item.youtube_url, icon: SVGIconKind.Youtube }); + } + const linkedIn = getLinkedInUrl(); + if (!isUndefined(linkedIn)) { + links.push({ label: 'LinkedIn', url: linkedIn, icon: SVGIconKind.LinkedIn }); + } + if (!isUndefined(item.bluesky_url)) { + links.push({ label: 'Bluesky', url: item.bluesky_url, icon: SVGIconKind.Bluesky }); + } + if (!isUndefined(item.blog_url)) { + links.push({ label: 'Blog', url: item.blog_url, icon: SVGIconKind.Blog }); + } + return links; + }; + + const codeLinks = (): LinkEntry[] => { + const item = itemInfo(); + if (isUndefined(item) || item === null) return []; + + const links: LinkEntry[] = []; + const repo = primaryRepo(); + if (!isUndefined(repo)) { + links.push({ label: 'Repository', url: repo.url, icon: SVGIconKind.GitHub }); + } + if (!isUndefined(item.docker_url)) { + links.push({ label: 'Docker', url: item.docker_url, icon: SVGIconKind.Docker }); + } + if (!isUndefined(item.package_manager_url)) { + links.push({ label: 'Package Manager', url: item.package_manager_url, icon: SVGIconKind.PackageManager }); + } + return links; + }; + + return ( + + {/* Loading state: data not yet fetched */} + +
+ +
+
+ + {/* Not found state: data loaded but item doesn't exist */} + +
+
+
Project not found
+
The requested project could not be found in the landscape.
+
+ Browse the CNCF Landscape +
+
+
+
+ + {/* Found state: render kiosk content */} + + {(item) => { + const i = item(); + return ( +
+ {/* Header */} +
+
+ +
+
{i.name}
+
+ + + + + + +
+ +
{description()}
+
+
+ + {/* Stats Bar */} + + {(gh) => ( +
+ +
+
{prettifyNumber(gh().stars)}
+
Stars
+
+
+ +
+
{prettifyNumber(gh().contributors.count)}
+
Contributors
+
+
+ +
+
{gh().license}
+
License
+
+
+ +
+
{new Date(gh().latest_release!.ts).toLocaleDateString()}
+
Latest Release
+
+
+
+ )} +
+ + {/* Action Cards Grid */} +
+ {/* Get Started */} + +
+
Get Started
+ + + + Website + + + + + + Documentation + + +
+
+ + {/* Contribute */} + + {(url) => ( +
+
Contribute
+ + + Good First Issues + +
+ )} +
+ + {/* Jobs */} + + {(url) => ( +
+
Jobs
+ + + Find jobs on GitJobs + +
+ )} +
+ + {/* Community */} + 0}> +
+
Community
+ + {(link) => ( + + + {link.label} + + )} + +
+
+ + {/* Social */} + 0}> +
+
Social
+ + {(link) => ( + + + {link.label} + + )} + +
+
+ + {/* Code */} + 0}> +
+
Code
+ + {(link) => ( + + + {link.label} + + )} + +
+
+
+ + {/* Footer */} +
+ Powered by CNCF Landscape +
+
+ ); + }} +
+
+ ); +}; + +export default KioskView;