diff options
| author | rxliuli <rxliuli@gmail.com> | 2025-11-04 05:03:50 +0800 |
|---|---|---|
| committer | rxliuli <rxliuli@gmail.com> | 2025-11-04 05:03:50 +0800 |
| commit | bce557cc2dc767628bed6aac87301a1be7c5431b (patch) | |
| tree | b51a051228d01fe3306cd7626d4a96768aadb944 /src/components/jet/today-card | |
init commit
Diffstat (limited to 'src/components/jet/today-card')
12 files changed, 1111 insertions, 0 deletions
diff --git a/src/components/jet/today-card/TodayCard.svelte b/src/components/jet/today-card/TodayCard.svelte new file mode 100644 index 0000000..84d760f --- /dev/null +++ b/src/components/jet/today-card/TodayCard.svelte @@ -0,0 +1,401 @@ +<script lang="ts"> + import type { TodayCard } from '@jet-app/app-store/api/models'; + + import Artwork, { + type Profile, + getNaturalProfile, + } from '~/components/Artwork.svelte'; + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import TodayCardMedia from '~/components/jet/today-card/TodayCardMedia.svelte'; + import TodayCardOverlay from '~/components/jet/today-card/TodayCardOverlay.svelte'; + import { isTodayCardMediaList } from '~/components/jet/today-card/media/TodayCardMediaList.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + import { colorAsString } from '~/utils/color'; + import { bestBackgroundColor } from './background-color-utils'; + + export let card: TodayCard; + + /** + * When set to `true`, this component will not enable the `clickAction` provided by the + * `card` + * + * This can be useful on the "story" page, where the card will link back to the page + * currently being viewed + */ + export let suppressClickAction: boolean = false; + + /** + * A `Profile` to override the default for the card's media + */ + export let artworkProfile: Profile | undefined = undefined; + + let useProtectionLayer: boolean; + let useBlurryProtectionLayer: boolean; + let useGradientProtectionLayer: boolean; + let useListStyle: boolean; + let accentColor: string; + + $: ({ + heading, + title, + inlineDescription, + titleArtwork, + overlay, + media, + editorialDisplayOptions, + style = 'light', + clickAction, + } = card); + $: action = suppressClickAction ? undefined : clickAction; + + $: { + const isAppEvent = media?.kind === 'appEvent'; + const isList = !!media && isTodayCardMediaList(media); + + useListStyle = isList; + useProtectionLayer = + editorialDisplayOptions?.useTextProtectionColor || + editorialDisplayOptions?.useMaterialBlur || + false; + useBlurryProtectionLayer = useProtectionLayer && !isAppEvent && !isList; + useGradientProtectionLayer = useProtectionLayer && isAppEvent; + accentColor = colorAsString(bestBackgroundColor(card.media)); + } +</script> + +<!-- + We don't wrap the entire card with an action if there is an `overlay`, since the overlay has + it's own link / action (and we don't want nesting `a` tags, of course). +--> +<LinkWrapper action={overlay || useListStyle ? null : action}> + <div + class="today-card" + class:light={style === 'light'} + class:dark={style === 'dark'} + class:white={style === 'white'} + class:list={useListStyle} + class:with-overlay={overlay} + style:--today-card-accent-color={accentColor} + > + {#if media && !useListStyle} + <TodayCardMedia {media} {artworkProfile} /> + {/if} + + <div class="wrapper"> + <div + class="information-layer" + class:with-gradient={useGradientProtectionLayer} + class:with-action={!!action} + > + <LinkWrapper action={useListStyle ? null : action}> + <div class="content-container"> + {#if useBlurryProtectionLayer} + <div class="protection-layer" /> + {/if} + + <div class="title-container"> + {#if heading && !titleArtwork} + <p class="badge"> + <LineClamp clamp={1}> + {heading} + </LineClamp> + </p> + {/if} + + {#if titleArtwork} + <div class="title-artwork-container"> + <Artwork + artwork={titleArtwork} + profile={getNaturalProfile( + titleArtwork, + )} + /> + </div> + {/if} + + {#if title && !titleArtwork} + <h3 class="title"> + <LinkWrapper + action={useListStyle ? action : null} + > + {@html sanitizeHtml(title)} + </LinkWrapper> + </h3> + {/if} + + {#if inlineDescription} + <LineClamp clamp={2}> + <p class="description"> + {@html sanitizeHtml(inlineDescription)} + </p> + </LineClamp> + {/if} + </div> + </div> + </LinkWrapper> + + {#if overlay} + <div + class="overlay" + class:blur-only={!useProtectionLayer} + class:dark={useProtectionLayer && style !== 'dark'} + class:light={useProtectionLayer && style === 'dark'} + > + <TodayCardOverlay + {overlay} + buttonVariant={useProtectionLayer + ? 'transparent' + : 'dark-gray'} + --text-color="var(--today-card-text-color)" + --text-accent-color="var(--today-card-text-accent-color)" + --text-accent-blend-mode="var(--today-card-text-accent-blend-mode)" + /> + </div> + {/if} + </div> + </div> + + {#if media && useListStyle} + <TodayCardMedia {media} {artworkProfile} /> + {/if} + </div> +</LinkWrapper> + +<style lang="scss"> + @property --gradient-color { + syntax: '<color>'; + inherits: true; + initial-value: #000; + } + + .today-card { + --today-card-gutter: 16px; + --today-card-border-radius: var( + --border-radius, + var(--global-border-radius-large) + ); + --protection-layer-bottom-offset: 0px; + --gradient-color: var(--today-card-accent-color); + background-color: var(--today-card-accent-color); + position: relative; + display: flex; + align-items: end; + height: 100%; + overflow: hidden; + color: var(--today-card-text-color); + container-type: size; + container-name: today-card; + border-radius: var(--today-card-border-radius); + box-shadow: var(--shadow-small); + } + + .today-card.with-overlay { + --protection-layer-bottom-offset: 80px; + } + + .today-card.light, + .today-card.dark { + --today-card-text-color: rgb(255, 255, 255); + --today-card-text-accent-color: rgba(255, 255, 255, 0.56); + --today-card-text-accent-blend-mode: plus-lighter; + --today-card-background-tint-color: rgba(0, 0, 0, 0.18); + } + + .today-card.white { + --today-card-text-color: var(--systemPrimary-onLight); + --today-card-text-accent-color: rgba(0, 0, 0, 0.56); + --today-card-background-tint-color: rgba(255, 255, 255, 0.33); + --today-card-text-accent-blend-mode: revert; + } + + .today-card :global(.artwork-component) { + z-index: unset; + } + + .wrapper { + position: absolute; + display: flex; + width: 100%; + height: 100%; + } + + .content-container { + position: relative; + } + + .information-layer { + position: relative; + display: flex; + flex-direction: column; + justify-content: end; + align-self: flex-end; + width: 100%; + height: 100%; + border-radius: var(--today-card-border-radius); + overflow: hidden; + } + + .information-layer > :global(a) { + display: flex; + flex-grow: 1; + flex-direction: column; + justify-content: end; + } + + .information-layer.with-gradient { + // A smooth bottom-to-top gradient with an intermediate stop at 60% of the accent color's + // opacity to ease the hard transition. + --gradient-color-end-position: 22%; + --gradient-fade-end-position: 50%; + background: linear-gradient( + 0deg, + var(--gradient-color) var(--gradient-color-end-position), + color-mix(in srgb, var(--gradient-color) 60%, transparent) + calc( + ( + var(--gradient-color-end-position) + + var(--gradient-fade-end-position) + ) / 2 + ), + transparent var(--gradient-fade-end-position) + ); + transition: --accent-color-end 500ms ease-out, --fade-end 350ms ease-out, + --gradient-color 350ms ease-out; + } + + .information-layer.with-gradient.with-action:has(> a:hover) { + // Darkens the color used in the gradient on hover + --gradient-color: color-mix( + in srgb, + var(--today-card-accent-color) 93%, + black + ); + } + + @container today-card (aspect-ratio >= 16/9) { + .information-layer.with-gradient { + --accent-color-end: 30%; + } + } + + .protection-layer { + --brightness: 0.95; + position: absolute; + width: 100%; + // On cards with overlays (app lockups at the bottom), we increase the height of the + // protection layer and shift it downward the same amount, so it is aligned to bottom + // of the overlay. + height: calc(100% + var(--protection-layer-bottom-offset) + 60px); + bottom: calc(-1 * var(--protection-layer-bottom-offset)); + background: var(--today-card-background-tint-color); + backdrop-filter: blur(34px) brightness(var(--brightness)) saturate(1.6) + contrast(1.1); + mask-image: linear-gradient( + to top, + black 30%, + rgba(0, 0, 0, 0.75) 70%, + rgba(0, 0, 0, 0.4) 86%, + transparent 100% + ); + transition: backdrop-filter 210ms ease-in; + } + + .information-layer:has(> a:hover) .protection-layer { + --brightness: 0.88; + } + + .badge { + font: var(--callout-emphasized); + margin-bottom: 4px; + mix-blend-mode: var(--today-card-text-accent-blend-mode); + color: var(--today-card-text-accent-color); + } + + .title-container { + width: auto; + position: relative; + padding: 0 var(--today-card-gutter) var(--today-card-gutter); + } + + @container today-card (orientation: landscape) { + .title-artwork-container { + width: 33%; + min-width: 200px; + max-width: 300px; + padding-bottom: 8px; + } + } + + @container today-card (orientation: portrait) { + .title-artwork-container { + max-width: 75%; + padding-bottom: 8px; + } + } + + .title { + font: var(--header-emphasized); + color: var(--today-card-text-color); + text-wrap: pretty; + } + + .description { + font: var(--body); + padding-top: calc(var(--today-card-gutter) / 2); + mix-blend-mode: var(--today-card-text-accent-blend-mode); + color: var(--today-card-text-accent-color); + text-wrap: pretty; + z-index: 1; + position: relative; + } + + .overlay { + z-index: 1; + position: relative; + padding: var(--today-card-gutter); + } + + .overlay.blur-only { + backdrop-filter: blur(50px); + } + + .overlay.light { + background-image: linear-gradient(rgba(225, 225, 225, 0.15) 0 0); + } + + .overlay.dark { + background-image: linear-gradient(rgba(0, 0, 0, 0.15) 0 0); + } + + .list { + background: var(--systemPrimary-onDark); + padding: var(--today-card-gutter) 0; + width: 100%; + flex-direction: column; + + @media (prefers-color-scheme: dark) { + --title-color: var(--systemPrimary); + background: var(--systemQuaternary); + + .title { + --today-card-text-color: var(--systemPrimary); + } + + .badge { + --today-card-text-accent-color: var(--systemSecondary); + } + } + } + + .list .wrapper { + position: relative; + height: auto; + width: 100%; + } + + .list .information-layer { + padding-top: 0; + } +</style> diff --git a/src/components/jet/today-card/TodayCardMedia.svelte b/src/components/jet/today-card/TodayCardMedia.svelte new file mode 100644 index 0000000..99f444f --- /dev/null +++ b/src/components/jet/today-card/TodayCardMedia.svelte @@ -0,0 +1,49 @@ +<script lang="ts"> + import type { TodayCardMedia } from '@jet-app/app-store/api/models'; + + import type { Profile } from '~/components/Artwork.svelte'; + import TodayCardMediaAppEvent, { + isTodayCardMediaAppEvent, + } from '~/components/jet/today-card/media/TodayCardMediaAppEvent.svelte'; + import TodayCardMediaAppIcon, { + isTodayCardMediAppIcon, + } from '~/components/jet/today-card/media/TodayCardMediaAppIcon.svelte'; + import TodayCardMediaBrandedSingleApp, { + isTodayCardMediaBrandedSingleApp, + } from '~/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte'; + import TodayCardMediaList, { + isTodayCardMediaList, + } from '~/components/jet/today-card/media/TodayCardMediaList.svelte'; + import TodayCardMediaRiver, { + isTodayCardMediaRiver, + } from '~/components/jet/today-card/media/TodayCardMediaRiver.svelte'; + import TodayCardMediaWithArtwork, { + isTodayCardMediaWithArtwork, + } from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte'; + import TodayCardMediaVideo, { + isTodayCardMediaVideo, + } from '~/components/jet/today-card/media/TodayCardMediaVideo.svelte'; + + export let media: TodayCardMedia; + + /** + * A `Profile` to override the default for the card's media + */ + export let artworkProfile: Profile | undefined = undefined; +</script> + +{#if isTodayCardMediaAppEvent(media)} + <TodayCardMediaAppEvent {media} {artworkProfile} /> +{:else if isTodayCardMediAppIcon(media)} + <TodayCardMediaAppIcon {media} /> +{:else if isTodayCardMediaBrandedSingleApp(media)} + <TodayCardMediaBrandedSingleApp {media} {artworkProfile} /> +{:else if isTodayCardMediaList(media)} + <TodayCardMediaList {media} /> +{:else if isTodayCardMediaWithArtwork(media)} + <TodayCardMediaWithArtwork {media} {artworkProfile} /> +{:else if isTodayCardMediaRiver(media)} + <TodayCardMediaRiver {media} /> +{:else if isTodayCardMediaVideo(media)} + <TodayCardMediaVideo {media} {artworkProfile} /> +{/if} diff --git a/src/components/jet/today-card/TodayCardOverlay.svelte b/src/components/jet/today-card/TodayCardOverlay.svelte new file mode 100644 index 0000000..4e3c405 --- /dev/null +++ b/src/components/jet/today-card/TodayCardOverlay.svelte @@ -0,0 +1,48 @@ +<script lang="ts" context="module"> + import type { + TodayCardOverlay, + TodayCardLockupOverlay, + } from '@jet-app/app-store/api/models'; + + export function isLockupOverlay( + overlay: TodayCardOverlay, + ): overlay is TodayCardLockupOverlay { + return overlay.kind === 'lockup'; + } +</script> + +<script lang="ts"> + import TodayCardLockupListOverlay, { + isLockupListOverlay, + } from '~/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte'; + import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte'; + + export let overlay: TodayCardOverlay; + export let buttonVariant: 'gray' | 'blue' | 'transparent' = 'transparent'; +</script> + +{#if isLockupOverlay(overlay)} + <div class="small-lockup-item-config"> + <SmallLockupItem + {buttonVariant} + item={overlay.lockup} + titleLineCount={1} + appIconProfile="app-icon" + /> + </div> +{:else if isLockupListOverlay(overlay)} + <TodayCardLockupListOverlay {overlay} /> +{/if} + +<style> + .small-lockup-item-config { + --title-color: var(--text-color, currentColor); + --subtitle-color: var(--text-accent-color, currentColor); + --linkColor: currentColor; + --eyebrow-color: var(--text-accent-color, currentColor); + --button-blend-mode: var(--text-accent-blend-mode); + --subtitle-blend-mode: var(--text-accent-blend-mode); + --eyebrow-blend-mode: var(--text-accent-blend-mode); + display: contents; + } +</style> diff --git a/src/components/jet/today-card/background-color-utils.ts b/src/components/jet/today-card/background-color-utils.ts new file mode 100644 index 0000000..c2c0fe6 --- /dev/null +++ b/src/components/jet/today-card/background-color-utils.ts @@ -0,0 +1,54 @@ +import { type Optional, isSome } from '@jet/environment/types/optional'; +import type { + Color, + TodayCardMedia, + TodayCardMediaWithArtwork, +} from '@jet-app/app-store/api/models'; + +import { isTodayCardMediaBrandedSingleApp } from './media/TodayCardMediaBrandedSingleApp.svelte'; +import { isTodayCardMediaAppEvent } from './media/TodayCardMediaAppEvent.svelte'; +import { isTodayCardMediaWithArtwork } from './media/TodayCardMediaWithArtwork.svelte'; + +const DEFAULT_COLOR: Color = { + type: 'named', + name: 'defaultBackground', +}; + +function getBackgroundFromMediaWithArtwork( + media: TodayCardMediaWithArtwork, +): Optional<Color> { + return ( + media.videos[0]?.preview.backgroundColor ?? + media.artworks[0]?.backgroundColor + ); +} + +/** + * Onyx App Store alternative to the `bestBackgroundColor` method that exists on + * the {@linkcode TodayCardMedia} type + * + * This is necessary because the functions on those class instances are not + * carried over to the client when serializing the view-model, making them + * impossible to call in a consistent way from our codebase + */ +export function bestBackgroundColor(media: Optional<TodayCardMedia>): Color { + if (isSome(media)) { + if (isTodayCardMediaAppEvent(media)) { + return media.tintColor; + } + + if (isTodayCardMediaBrandedSingleApp(media)) { + return ( + getBackgroundFromMediaWithArtwork(media) ?? + media.icon.backgroundColor ?? + DEFAULT_COLOR + ); + } + + if (isTodayCardMediaWithArtwork(media)) { + return getBackgroundFromMediaWithArtwork(media) ?? DEFAULT_COLOR; + } + } + + return DEFAULT_COLOR; +} diff --git a/src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte b/src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte new file mode 100644 index 0000000..1faa933 --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte @@ -0,0 +1,78 @@ +<script lang="ts" context="module"> + import type { + TodayCardMedia, + TodayCardMediaAppEvent, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediaAppEvent( + media: TodayCardMedia, + ): media is TodayCardMediaAppEvent { + return media.kind === 'appEvent'; + } +</script> + +<script lang="ts"> + import type { Profile } from '~/components/Artwork.svelte'; + import TodayCardMediaWithArtwork from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte'; + import TodayCardMediaVideo from '~/components/jet/today-card/media/TodayCardMediaVideo.svelte'; + import AppEventDate from '~/components/AppEventDate.svelte'; + + export let media: TodayCardMediaAppEvent; + + /** + * A `Profile` to override the default for the card's media + */ + export let artworkProfile: Profile | undefined = undefined; +</script> + +<div class="event-container"> + <span class="time-container"> + <AppEventDate formattedDates={media.formattedDates} /> + </span> + + <div class="artwork-container"> + {#if media.videos.length > 0} + <TodayCardMediaVideo {media} {artworkProfile} /> + {:else if media.artworks.length > 0} + <TodayCardMediaWithArtwork {media} {artworkProfile} /> + {/if} + </div> +</div> + +<style> + .event-container { + --today-card-border-width: 4px; + border: var(--today-card-border-width) solid + var(--today-card-accent-color); + border-radius: var(--today-card-border-radius); + position: relative; + aspect-ratio: 0.75; + width: 100%; + height: 100%; + overflow: hidden; + } + + @container (orientation: landscape) { + .event-container { + aspect-ratio: 16/9; + } + } + + .artwork-container { + height: 100%; + border-radius: calc( + var(--today-card-border-radius) - var(--today-card-border-width) + ); + } + + .time-container :global(time), + .time-container :global(span) { + background: var(--today-card-accent-color); + border-end-end-radius: var(--today-card-border-radius); + font: var(--headline); + padding: 6px 10px 6px 8px; + position: absolute; + top: 0; + z-index: 3; + } +</style> diff --git a/src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte b/src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte new file mode 100644 index 0000000..a6db985 --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte @@ -0,0 +1,62 @@ +<script lang="ts" context="module"> + import type { + TodayCardMedia, + TodayCardMediaAppIcon, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediAppIcon( + media: TodayCardMedia, + ): media is TodayCardMediaAppIcon { + return media.kind === 'appIcon'; + } +</script> + +<script lang="ts"> + import AppIcon from '~/components/AppIcon.svelte'; + import { colorAsString } from '~/utils/color'; + + export let media: TodayCardMediaAppIcon; + + $: backgroundColor = media.icon.backgroundColor + ? colorAsString(media.icon.backgroundColor) + : null; +</script> + +<div class="container" style:--background-color={backgroundColor}> + <div class="artwork-container"> + <AppIcon + icon={media.icon} + profile="app-icon-xlarge" + fixedWidth={false} + /> + </div> +</div> + +<style> + .container { + height: 100%; + width: 100%; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--background-color); + border-radius: var(--today-card-border-radius); + } + + .artwork-container { + width: 50%; + height: 50%; + } + + @container (orientation: landscape) { + .container { + align-items: start; + padding-top: 5%; + } + + .artwork-container { + width: 30%; + height: 30%; + } + } +</style> diff --git a/src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte b/src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte new file mode 100644 index 0000000..dfdaa0f --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte @@ -0,0 +1,41 @@ +<script lang="ts" context="module"> + import type { + TodayCardMedia, + TodayCardMediaBrandedSingleApp, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediaBrandedSingleApp( + media: TodayCardMedia, + ): media is TodayCardMediaBrandedSingleApp { + return media.kind === 'brandedSingleApp'; + } +</script> + +<script lang="ts"> + import TodayCardMediaWithArtwork from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte'; + import TodayCardMediaVideo from '~/components/jet/today-card/media/TodayCardMediaVideo.svelte'; + import type { Profile } from '~/components/Artwork.svelte'; + + export let media: TodayCardMediaBrandedSingleApp; + + /** + * A `Profile` to override the default for the card's media + */ + export let artworkProfile: Profile | undefined = undefined; + + // There is a small but non-zero set of old legacy Today Cards that can appear on the Today page, + // and those cards have their safe area on the left side of the artwork, rather than the center, + // like all the modern artwork. For those cases, we pin the artwork to the left edge of the card. + $: pinnedToLeft = + media.artworkLayoutsWithMetrics[0].ltr.collapsedLayoutInsets.left < 0; +</script> + +{#if media.videos.length > 0} + <TodayCardMediaVideo {media} {artworkProfile} /> +{:else if media.artworks.length > 0} + <TodayCardMediaWithArtwork + {media} + {artworkProfile} + pinArtworkToLeft={pinnedToLeft} + /> +{/if} diff --git a/src/components/jet/today-card/media/TodayCardMediaList.svelte b/src/components/jet/today-card/media/TodayCardMediaList.svelte new file mode 100644 index 0000000..00f8688 --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaList.svelte @@ -0,0 +1,86 @@ +<script lang="ts" context="module"> + import type { + TodayCardMedia, + TodayCardMediaList, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediaList( + media: TodayCardMedia, + ): media is TodayCardMediaList { + return media.kind === 'list'; + } +</script> + +<script lang="ts"> + import { onMount } from 'svelte'; + import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte'; + + export let media: TodayCardMediaList; + + let container: HTMLDivElement; + let fadeTop = '0%'; + let fadeBottom = '0%'; + + function calculateFadeAmounts() { + const { scrollTop, scrollHeight, clientHeight } = container; + + fadeTop = scrollTop > 0 ? '10%' : `${scrollTop}%`; + fadeBottom = scrollTop + clientHeight < scrollHeight - 1 ? '15%' : '0%'; + } + + onMount(() => { + calculateFadeAmounts(); + container.addEventListener('scroll', calculateFadeAmounts); + + return () => + container.removeEventListener('scroll', calculateFadeAmounts); + }); +</script> + +<div + class="container" + style:--fade-top-size={fadeTop} + style:--fade-bottom-size={fadeBottom} + bind:this={container} +> + <ul> + {#each media.lockups as item} + <li> + <SmallLockupItem {item} /> + </li> + {/each} + </ul> +</div> + +<style> + @property --fade-top-size { + syntax: '<percentage>'; + inherits: false; + initial-value: 0%; + } + + @property --fade-bottom-size { + syntax: '<percentage>'; + inherits: false; + initial-value: 0%; + } + + .container { + width: 100%; + overflow: scroll; + padding: 0 var(--today-card-gutter); + mask-image: linear-gradient( + to bottom, + transparent 0%, + black var(--fade-top-size), + black calc(100% - var(--fade-bottom-size)), + transparent 100% + ); + transition: --fade-top-size 105ms cubic-bezier(0.5, 1, 0.89, 1), + --fade-bottom-size 420ms cubic-bezier(0.45, 0, 0.55, 1); + } + + li { + margin-bottom: 16px; + } +</style> diff --git a/src/components/jet/today-card/media/TodayCardMediaRiver.svelte b/src/components/jet/today-card/media/TodayCardMediaRiver.svelte new file mode 100644 index 0000000..d3f9666 --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaRiver.svelte @@ -0,0 +1,78 @@ +<script lang="ts" context="module"> + import type { + TodayCardMedia, + TodayCardMediaRiver, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediaRiver( + media: TodayCardMedia, + ): media is TodayCardMediaRiver { + return media.kind === 'river'; + } +</script> + +<script lang="ts"> + import { + getBackgroundGradientCSSVarsFromArtworks, + getLuminanceForRGB, + } from '~/utils/color'; + import AppIconRiver from '~/components/AppIconRiver.svelte'; + + /** + * The actual properties of {@linkcode TodayCardMediaRiver} that are required + * to render this component + */ + type TodayCardMediaRiverRequirements = Pick<TodayCardMediaRiver, 'lockups'>; + + export let media: TodayCardMediaRiverRequirements; + + $: icons = media.lockups.map((lockup) => lockup.icon); + $: backgroundGradientCssVars = getBackgroundGradientCSSVarsFromArtworks( + icons, + { + // sorts from darkest to lightest + sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b), + }, + ); +</script> + +<div class="container" style={backgroundGradientCssVars}> + {#if icons.length} + <AppIconRiver {icons} /> + {/if} +</div> + +<style> + .container { + --app-icon-river-icon-width: 96px; + height: 100%; + width: 100%; + padding-top: 10%; + overflow: hidden; + border-radius: var(--today-card-border-radius); + background: radial-gradient( + circle at 3% -50%, + var(--top-left, #000) 20%, + transparent 70% + ), + radial-gradient( + circle at -50% 120%, + var(--bottom-left, #000) 40%, + transparent 80% + ), + radial-gradient( + circle at 140% -50%, + var(--top-right, #000) 60%, + transparent 80% + ), + radial-gradient( + circle at 62% 100%, + var(--bottom-right, #000) 50%, + transparent 100% + ); + + @media (--range-small-only) { + padding-top: 5%; + } + } +</style> diff --git a/src/components/jet/today-card/media/TodayCardMediaVideo.svelte b/src/components/jet/today-card/media/TodayCardMediaVideo.svelte new file mode 100644 index 0000000..f2524c6 --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaVideo.svelte @@ -0,0 +1,72 @@ +<script lang="ts" context="module"> + import type { + TodayCardMedia, + TodayCardMediaVideo, + Video as VideoModel, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediaVideo( + media: TodayCardMedia, + ): media is TodayCardMediaVideo { + return ( + media.kind === 'video' || + (media.kind === 'artwork' && 'videos' in media) + ); + } +</script> + +<script lang="ts"> + import mediaQueries from '~/utils/media-queries'; + import type { Profile as ArtworkProfile } from '~/components/Artwork.svelte'; + import Video from '~/components/jet/Video.svelte'; + import { colorAsString } from '~/utils/color'; + + export let media: TodayCardMediaVideo; + + /** + * A `Profile` to override the default for the card's media + */ + export let artworkProfile: ArtworkProfile | undefined = undefined; + + let videoToDisplay: VideoModel | undefined; + $: videoToDisplay = media.videos[0]; + + let profile: ArtworkProfile; + $: profile = + artworkProfile ?? + ($mediaQueries === 'small' ? 'card' : 'card-horizontal'); + $: backgroundColor = videoToDisplay?.preview.backgroundColor + ? colorAsString(videoToDisplay?.preview.backgroundColor) + : null; +</script> + +{#if videoToDisplay} + <div class="video-wrapper" style:--background-color={backgroundColor}> + <Video + autoplay + loop + {profile} + useControls={false} + video={videoToDisplay} + /> + </div> +{/if} + +<style> + .video-wrapper { + background: black; + aspect-ratio: 3/4; + width: 100%; + position: relative; + overflow: hidden; + border-radius: var(--today-card-border-radius); + background-color: var(--background-color); + } + + @container (orientation: landscape) { + .video-wrapper { + aspect-ratio: 16/9; + height: 100%; + } + } +</style> diff --git a/src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte b/src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte new file mode 100644 index 0000000..e604708 --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte @@ -0,0 +1,100 @@ +<script lang="ts" context="module"> + import type { + Artwork as ArtworkModel, + TodayCardMedia, + TodayCardMediaWithArtwork, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediaWithArtwork( + media: TodayCardMedia, + ): media is TodayCardMediaWithArtwork { + return ( + media.kind === 'artwork' && + 'artworks' in media && + Array.isArray(media.artworks) && + media.artworks.length > 0 + ); + } +</script> + +<script lang="ts"> + import Artwork, { + type Profile as ArtworkProfile, + } from '~/components/Artwork.svelte'; + + export let media: TodayCardMediaWithArtwork; + + export let pinArtworkToLeft: boolean = false; + + /** + * A `Profile` to override the default for the card's media + */ + export let artworkProfile: ArtworkProfile | undefined = undefined; + + let artworkToDisplay: ArtworkModel; + // Today Card artwork comes back from Jet with a width of 800px, even though the source artwork + // is _much_ larger. The shared `Artwork` component doesn't let us render an image beyond the + // artwork's `width` and `height` properties, and we absolutely need to render these images + // larger than 800px wide, so we are forcing these new upper bounds for the artworks dimensions. + // Eventually, we should rethink this and have the proper dimensions come back from Jet: + // rdar://148730199 (Bigger images for TodayCard) + $: artworkToDisplay = Object.assign({}, media.artworks[0], { + width: 3840, + height: 2160, + }); +</script> + +{#if artworkProfile} + <Artwork profile={artworkProfile} artwork={artworkToDisplay} /> +{:else} + <div class="wrapper"> + <div class="artwork-container portrait"> + <Artwork profile="card" artwork={artworkToDisplay} /> + </div> + + <div + class="artwork-container landscape" + class:pinned-to-left={pinArtworkToLeft} + > + <Artwork profile="card-horizontal" artwork={artworkToDisplay} /> + </div> + </div> +{/if} + +<style> + .wrapper, + .artwork-container { + height: 100%; + width: 100%; + } + + .wrapper .artwork-container :global(.artwork-component), + .wrapper .artwork-container :global(img) { + object-fit: cover; + height: 100%; + } + + .pinned-to-left { + --artwork-override-object-position: left; + } + + @container (orientation: landscape) { + .artwork-container.landscape { + display: block; + } + + .artwork-container.portrait { + display: none; + } + } + + @container (orientation: portrait) { + .artwork-container.landscape { + display: none; + } + + .artwork-container.portrait { + display: block; + } + } +</style> diff --git a/src/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte b/src/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte new file mode 100644 index 0000000..1e7d297 --- /dev/null +++ b/src/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte @@ -0,0 +1,42 @@ +<script lang="ts" context="module"> + import type { + TodayCardOverlay, + TodayCardLockupListOverlay, + } from '@jet-app/app-store/api/models'; + + export function isLockupListOverlay( + overlay: TodayCardOverlay, + ): overlay is TodayCardLockupListOverlay { + return overlay.kind === 'lockupList'; + } +</script> + +<script lang="ts"> + import AppIcon from '~/components/AppIcon.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let overlay: TodayCardLockupListOverlay; +</script> + +<div class="lockup-list"> + {#each overlay.lockups as lockup} + <LinkWrapper action={lockup.clickAction}> + <AppIcon icon={lockup.icon} /> + </LinkWrapper> + {/each} +</div> + +<style> + .lockup-list { + display: flex; + gap: 12px; + + @media (--range-xsmall-only) and (--sidebar-visible) { + gap: 10px; + } + + @media (--range-small-up) { + gap: 16px; + } + } +</style> |
