Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions projects/client/i18n/meta/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -9671,6 +9671,26 @@
"default": "Community Apps",
"description": "Heading for the section listing community-built third-party apps, which count toward the connected-apps limit."
},
"heading_official_apps": {
"default": "Official Apps",
"description": "Heading for the catalog of official Trakt apps."
},
"description_official_apps": {
"default": "Discover apps created and maintained by the Trakt team.",
"description": "Description shown under the Official Apps heading explaining who creates and maintains the apps."
},
"tab_text_official_apps_apple": {
"default": "Apple",
"description": "Tab label for official apps available on Apple platforms."
},
"tab_text_official_apps_android": {
"default": "Android",
"description": "Tab label for official apps available on Android."
},
"tab_text_official_apps_web": {
"default": "Web",
"description": "Tab label for official apps available on the web."
},
"warning_connected_apps_limit": {
"default": "You've reached your connected apps limit. Upgrade to VIP to connect more.",
"description": "Upsell copy shown to free users who have reached their connected-apps limit, encouraging them to upgrade to VIP for a higher limit."
Expand All @@ -9687,6 +9707,14 @@
"default": "OAuth applications you've registered as a developer.",
"description": "Description shown under the API Applications heading explaining what the listed applications are."
},
"heading_public_api_docs": {
"default": "Public API Docs",
"description": "Title for the row linking to the public Trakt API documentation."
},
"description_public_api_docs": {
"default": "Build apps and integrations with the Trakt API.",
"description": "Subtitle for the row linking to the public Trakt API documentation."
},
"text_no_api_applications": {
"default": "You haven't created any API applications.",
"description": "Empty-state text shown when the user has not created any API applications."
Expand Down
27 changes: 27 additions & 0 deletions projects/client/src/lib/sections/settings/AppsSettings.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import * as m from '$lib/features/i18n/messages.ts';
import { UrlBuilder } from '$lib/utils/url/UrlBuilder.ts';
import AppsSettings from './AppsSettings.svelte';

import { render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';

describe('AppsSettings', () => {
it('should display the official apps catalog first', () => {
render(AppsSettings);

const officialAppsTitle = screen.getByText(m.heading_official_apps());
const applicationsTitle = screen.getByText(m.heading_apps_settings());

expect(
officialAppsTitle.compareDocumentPosition(applicationsTitle),
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(screen.getByText('Showly')).toBeInTheDocument();
const apiDocsLink = screen.getByRole('link', {
name: new RegExp(m.heading_public_api_docs()),
});

expect(apiDocsLink).toHaveAttribute('href', UrlBuilder.docs.api());
expect(apiDocsLink).toHaveAttribute('target', '_blank');
expect(apiDocsLink).toHaveAttribute('rel', 'noopener noreferrer');
});
});
22 changes: 22 additions & 0 deletions projects/client/src/lib/sections/settings/AppsSettings.svelte
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
<script lang="ts">
import CodeIcon from "$lib/components/icons/CodeIcon.svelte";
import GlobeIcon from "$lib/components/icons/GlobeIcon.svelte";
import PlugIcon from "$lib/components/icons/PlugIcon.svelte";
import * as m from "$lib/features/i18n/messages.ts";
import { UrlBuilder } from "$lib/utils/url/UrlBuilder.ts";
import OfficialAppsGallery from "./_internal/apps/OfficialAppsGallery.svelte";
import SettingsGroupCard from "./_internal/SettingsGroupCard.svelte";
import SettingsGroupRow from "./_internal/SettingsGroupRow.svelte";
import SettingsSection from "./_internal/SettingsSection.svelte";
</script>

<div class="trakt-apps-settings">
<SettingsSection
title={m.heading_official_apps()}
description={m.description_official_apps()}
>
<OfficialAppsGallery />
</SettingsSection>

<SettingsGroupCard
title={m.heading_apps_settings()}
description={m.description_apps_settings()}
Expand All @@ -33,6 +43,18 @@
<CodeIcon />
{/snippet}
</SettingsGroupRow>

<SettingsGroupRow
title={m.heading_public_api_docs()}
description={m.description_public_api_docs()}
variant="link"
href={UrlBuilder.docs.api()}
target="_blank"
>
{#snippet icon()}
<GlobeIcon />
{/snippet}
</SettingsGroupRow>
</SettingsGroupCard>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
type LinkRowProps = {
variant: "link";
href: string;
target?: "_blank";
};

type CustomRowProps = {
Expand Down Expand Up @@ -64,7 +65,12 @@
{/snippet}

{#if rest.variant === "link"}
<a class="trakt-settings-group-row" href={rest.href}>
<a
class="trakt-settings-group-row"
href={rest.href}
target={rest.target}
rel={rest.target === "_blank" ? "noopener noreferrer" : undefined}
>
{@render rowContent()}
</a>
{:else if rest.variant === "button"}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export type OfficialApp = Readonly<{
name: string;
iconUrl: string;
destinations: ReadonlyArray<
Readonly<{
store: 'app-store' | 'google-play' | 'web';
href: string;
}>
>;
}>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { OfficialApp } from './OfficialApp.ts';
import OfficialAppTile from './OfficialAppTile.svelte';

import { render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';

const destination = {
store: 'google-play',
href: 'https://play.google.com/store/apps/details?id=tv.trakt.trakt',
} as const;

const androidApp: OfficialApp = {
name: 'Trakt',
iconUrl: '/images/apps/official/trakt-android.webp',
destinations: [destination],
};

describe('OfficialAppTile', () => {
it('should render an official app as a store link', () => {
const { container } = render(OfficialAppTile, {
props: { app: androidApp, destination },
});

const storeLink = screen.getByRole('link', { name: androidApp.name });

expect(storeLink).toHaveAttribute('href', destination.href);
expect(storeLink).toHaveAttribute('target', '_blank');
expect(screen.getByText(androidApp.name)).toBeInTheDocument();
expect(container.querySelector('img')).toHaveAttribute(
'src',
androidApp.iconUrl,
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
<script lang="ts">
import type { OfficialApp } from "./OfficialApp.ts";

type AppDestination = OfficialApp["destinations"][number];

const {
app,
destination,
}: {
app: OfficialApp;
destination: AppDestination;
} = $props();
</script>

<li class="trakt-official-app-tile">
<a
class="tile-content"
href={destination.href}
target="_blank"
rel="external noreferrer"
>
<span class="app-icon" data-store={destination.store}>
<img src={app.iconUrl} alt="" loading="lazy" decoding="async" />
</span>

<span class="app-name small">{app.name}</span>
</a>
</li>

<style lang="scss">
@use "$style/scss/mixins/index" as *;

.trakt-official-app-tile {
min-width: 0;
}

.tile-content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ni-6);

min-width: 0;
height: 100%;
padding: var(--ni-8);
box-sizing: border-box;

border-radius: var(--border-radius-m);
color: inherit;
text-decoration: none;

-webkit-tap-highlight-color: transparent;
cursor: pointer;

transition: background var(--transition-increment) ease-in-out;

&:focus-visible {
outline: var(--ni-2) solid var(--color-background-purple);
outline-offset: var(--ni-2);
}

@include for-mouse {
&:hover {
background: color-mix(
in srgb,
var(--color-foreground) 5%,
transparent
);
}
}

&:active {
background: color-mix(
in srgb,
var(--color-foreground) 8%,
transparent
);
}
}

.app-icon {
display: block;
width: 70%;
max-width: calc(var(--ni-96) * 0.7);
aspect-ratio: 1;

overflow: hidden;

&[data-store="google-play"] {
border-radius: 50%;
}

&[data-store="app-store"] {
border-radius: calc(var(--border-radius-l) - var(--ni-1));
}

img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
}

.app-name {
max-width: 100%;

overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;

line-height: 1.25;
line-clamp: 2;
-webkit-line-clamp: 2;
text-align: center;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import * as m from '$lib/features/i18n/messages.ts';
import OfficialAppsGallery from './OfficialAppsGallery.svelte';
import { officialAppCatalog } from './officialAppCatalog.ts';

import { fireEvent, render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';

describe('OfficialAppsGallery', () => {
it('should filter the app tiles by platform', async () => {
const { container } = render(OfficialAppsGallery);

expect(screen.getByText('Trakt')).toBeInTheDocument();
expect(screen.getByText('Showly')).toBeInTheDocument();
expect(screen.getByText('Watcht')).toBeInTheDocument();
expect(screen.getByText('Rippple')).toBeInTheDocument();
expect(screen.queryByText('Trakt Time')).not.toBeInTheDocument();
expect(container.querySelectorAll('img')).toHaveLength(4);

await fireEvent.click(
screen.getByRole('tab', {
name: m.tab_text_official_apps_android(),
}),
);

expect(screen.getByText('Trakt')).toBeInTheDocument();
expect(screen.getByText('Showly')).toBeInTheDocument();
expect(screen.queryByText('Watcht')).not.toBeInTheDocument();
expect(screen.queryByText('Trakt Time')).not.toBeInTheDocument();

await fireEvent.click(
screen.getByRole('tab', {
name: m.tab_text_official_apps_web(),
}),
);

expect(screen.getByText('Trakt Time')).toBeInTheDocument();
expect(screen.queryByText('Showly')).not.toBeInTheDocument();
});

it('should link Showly to the store for the active platform', async () => {
render(OfficialAppsGallery);

const showly = officialAppCatalog.find((app) => app.name === 'Showly');
const appStore = showly?.destinations.find(
({ store }) => store === 'app-store',
);
const googlePlay = showly?.destinations.find(
({ store }) => store === 'google-play',
);

expect(screen.getByRole('link', { name: 'Showly' }))
.toHaveAttribute('href', appStore?.href);

await fireEvent.click(
screen.getByRole('tab', {
name: m.tab_text_official_apps_android(),
}),
);

expect(screen.getByRole('link', { name: 'Showly' }))
.toHaveAttribute('href', googlePlay?.href);
});
});
Loading