Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add item diff page #778

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
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
17 changes: 14 additions & 3 deletions apps/web/app/[language]/item/[id]/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import { ContentChanceColumn, ContentQuantityColumn, ItemContentQuantityColumn }
import type { TODO } from '@/lib/todo';
import { pageView } from '@/lib/pageView';
import { GuildUpgradeLink } from '@/components/GuildUpgrade/GuildUpgradeLink';
import { FlexRow } from '@gw2treasures/ui/components/Layout/FlexRow';
import { parseIcon } from '@/lib/parseIcon';

export interface ItemPageComponentProps {
language: Language;
Expand Down Expand Up @@ -74,10 +76,12 @@ export const ItemPageComponent: AsyncComponent<ItemPageComponentProps> = async (

const compareByName = compareLocalizedName(language);

const icon = parseIcon(data.icon);

return (
<DetailLayout
title={data.name || data.chat_link}
icon={item.icon}
icon={icon?.id === item.icon?.id ? item.icon : (icon ? { ...icon, color: null } : null)}
className={rarityClasses[data.rarity]}
breadcrumb={`Item › ${data.type}${data.details ? ` › ${data.details?.type}` : ''}`}
infobox={<ItemInfobox item={item} data={data} language={language}/>}
Expand All @@ -94,7 +98,7 @@ export const ItemPageComponent: AsyncComponent<ItemPageComponentProps> = async (
)}

<TableOfContentAnchor id="tooltip">Tooltip</TableOfContentAnchor>
<ItemTooltip item={data} language={language}/>
<ItemTooltip item={data} language={language} hideTitle/>

{item.unlocksSkinIds.length > 0 && (
<>
Expand Down Expand Up @@ -267,7 +271,14 @@ export const ItemPageComponent: AsyncComponent<ItemPageComponentProps> = async (
</Tooltip>
</td>
<td><FormatDate date={history.revision.createdAt} relative/></td>
<td>{history.revisionId !== revision.id && <Link href={`/item/${item.id}/${history.revisionId}`}>View</Link>}</td>
<td>
{history.revisionId !== revision.id && (
<FlexRow>
<Link href={`/item/${item.id}/${history.revisionId}`}>View</Link> ·
<Link href={`/item/diff/${history.revisionId}/${revision.id}`}>Compare</Link>
</FlexRow>
)}
</td>
</tr>
))}
</tbody>
Expand Down
90 changes: 90 additions & 0 deletions apps/web/app/[language]/item/diff/[a]/[b]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import Link from 'next/link';
import type { Gw2Api } from 'gw2-api-types';
import { DiffLayout, DiffLayoutHeader, DiffLayoutRow } from '@/components/Layout/DiffLayout';
import { EntityIcon } from '@/components/Entity/EntityIcon';
import { parseIcon } from '@/lib/parseIcon';
import { FormatDate } from '@/components/Format/FormatDate';
import { Notice } from '@gw2treasures/ui/components/Notice/Notice';
import { Separator } from '@gw2treasures/ui/components/Layout/Separator';
import { Json } from '@/components/Format/Json';
import { notFound } from 'next/navigation';
import { Fragment } from 'react';
import { db } from '@/lib/prisma';
import { remember } from '@/lib/remember';
import { renderDefense, renderWeaponStrength } from '@/components/Item/ItemTooltip.client';
import { createTooltip } from '@/components/Item/ItemTooltip';
import type { Language } from '@gw2treasures/database';

const getRevisions = remember(60, async function getRevisions(idA: string, idB: string) {
const [a, b] = await Promise.all([
db?.revision.findUnique({ where: { id: idA, entity: 'Item' }}),
db?.revision.findUnique({ where: { id: idB, entity: 'Item' }}),
]);

if(!a || !b) {
notFound();
}

return { a, b };
});

export default async function ItemDiffPage({ params }: { params: { a: string, b: string, language: Language }}) {
const idA = params.a.toString();
const idB = params.b.toString();

const { a, b } = await getRevisions(idA, idB);

const dataA: Gw2Api.Item = JSON.parse(a.data);
const dataB: Gw2Api.Item = JSON.parse(b.data);

const iconA = parseIcon(dataA.icon);
const iconB = parseIcon(dataB.icon);

const tooltipA = await createTooltip(dataA, params.language);
const tooltipB = await createTooltip(dataB, params.language);

return (
<DiffLayout>
<DiffLayoutHeader icons={[
iconA && <EntityIcon icon={iconA} size={48}/>,
iconB && <EntityIcon icon={iconB} size={48}/>,
]} title={[
dataA.name,
dataB.name,
]} subtitle={[
<Fragment key="a"><FormatDate date={a.createdAt}/> (<Link href={`/build/${a.buildId}`}>Build {a.buildId}</Link>) ▪ <Link href={`/item/${dataA.id}/${a.id}`}>View revision</Link></Fragment>,
<Fragment key="b"><FormatDate date={b.createdAt}/> (<Link href={`/build/${b.buildId}`}>Build {b.buildId}</Link>) ▪ <Link href={`/item/${dataB.id}/${b.id}`}>View revision</Link></Fragment>,
]}/>

{dataA.id !== dataB.id && (
<div style={{ padding: 16, paddingBottom: 0 }}>
<Notice>You are comparing two different items</Notice>
</div>
)}

{a.createdAt > b.createdAt && (
<div style={{ padding: 16, paddingBottom: 0 }}>
<Notice>You are comparing an old version against a newer version. <Link href={`/item/diff/${b.id}/${a.id}`}>Switch around</Link></Notice>
</div>
)}

<DiffLayoutRow left={renderWeaponStrength(tooltipA)} right={renderWeaponStrength(tooltipB)} changed={tooltipA.weaponStrength?.min !== tooltipB.weaponStrength?.min || tooltipA.weaponStrength?.max !== tooltipB.weaponStrength?.max}/>
<DiffLayoutRow left={renderDefense(tooltipA)} right={renderDefense(tooltipB)} changed={tooltipA.defense !== tooltipB.defense}/>

{(tooltipA.attributes || tooltipB.attributes) && (
[...Array(Math.max(tooltipA.attributes?.length ?? 0, tooltipB.attributes?.length ?? 0)).keys()].map((_, i) => (
<DiffLayoutRow key={i}
left={tooltipA.attributes?.[i] && `+${tooltipA.attributes[i].value} ${tooltipA.attributes[i].label}`}
right={tooltipB.attributes?.[i] && `+${tooltipB.attributes[i].value} ${tooltipB.attributes[i].label}`}
changed={tooltipA.attributes?.[i].value !== tooltipB.attributes?.[i].value || tooltipA.attributes?.[i].label !== tooltipB.attributes?.[i].label}/>
))
)}

{/* <DiffLayoutRow left={<ClientItemTooltip tooltip={tooltipA} hideTitle/>} right={<ClientItemTooltip tooltip={tooltipB} hideTitle/>}/> */}

<DiffLayoutRow left={<Separator/>} right={<Separator/>}/>
<DiffLayoutRow left={<Json data={dataA} borderless/>} right={<Json data={dataB} borderless/>} changed/>
</DiffLayout>
);
};

15 changes: 9 additions & 6 deletions apps/web/components/Item/ItemLinkTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,16 @@ export const ItemLinkTooltip: FC<ItemLinkTooltipProps> = ({ item, language, revi

return (
<div className={rarityStyles[item.rarity]}>
<div className={cx(styles.title)}>
{item.icon && (<EntityIcon icon={item.icon} size={32}/>)}
{localizedName(item, language)}
</div>

<ErrorBoundary fallback={<span>Error</span>}>
{tooltip.loading && <div className={styles.loading}><Skeleton/><br/><Skeleton width={120}/></div>}
{tooltip.loading && (
<>
<div className={cx(styles.title)}>
{item.icon && (<EntityIcon icon={item.icon} size={32}/>)}
{localizedName(item, language)}
</div>
<div className={styles.loading}><Skeleton/><br/><Skeleton width={120}/></div>
</>
)}
{!tooltip.loading && <ClientItemTooltip tooltip={tooltip.data}/>}
</ErrorBoundary>
</div>
Expand Down
61 changes: 47 additions & 14 deletions apps/web/components/Item/ItemTooltip.client.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type FC, Fragment } from 'react';
import { type FC, Fragment, type ReactNode } from 'react';
import { FormatNumber } from '../Format/FormatNumber';
import { ItemTooltip } from './ItemTooltip';
import { Rarity } from './Rarity';
Expand All @@ -10,14 +10,14 @@ import { Icon } from '@gw2treasures/ui';
import { EntityIcon } from '@/components/Entity/EntityIcon';
import { DyeColor } from '../Color/DyeColor';
import { hexToRgb } from '../Color/hex-to-rgb';
import { FlexRow } from '@gw2treasures/ui/components/Layout/FlexRow';
import { Tip } from '@gw2treasures/ui/components/Tip/Tip';

export interface ClientItemTooltipProps {
tooltip: ItemTooltip;
hideTitle?: boolean;
};

function renderAttributes(attributes: ItemTooltip['attributes']) {
export function renderAttributes(attributes: ItemTooltip['attributes']) {
if(!attributes) {
return;
}
Expand Down Expand Up @@ -97,10 +97,30 @@ function renderConsumable(consumable: ItemTooltip['consumable']) {
);
}

export const ClientItemTooltip: FC<ClientItemTooltipProps> = ({ tooltip }) => {
const data = [
tooltip.weaponStrength && (<>{tooltip.weaponStrength.label}: <FormatNumber value={tooltip.weaponStrength.min} className={styles.value}/> – <FormatNumber value={tooltip.weaponStrength.max} className={styles.value}/></>),
tooltip.defense && <>{tooltip.defense.label}: <FormatNumber value={tooltip.defense.value} className={styles.value}/></>,
export const ClientItemTooltip: FC<ClientItemTooltipProps> = ({ tooltip, hideTitle = false }) => {
const data = renderItemTooltipRows(tooltip);

return (
<div>
{!hideTitle && (
<div className={styles.title}>
{tooltip.icon && (<EntityIcon icon={tooltip.icon} size={32}/>)}
{tooltip.name}
</div>
)}

{data.filter(isTruthy).map((content, index) => {
// eslint-disable-next-line react/no-array-index-key
return <div className={styles.row} key={index}>{content}</div>;
})}
</div>
);
};

export function renderItemTooltipRows(tooltip: ItemTooltip): ReactNode[] {
return [
renderWeaponStrength(tooltip),
renderDefense(tooltip),
renderAttributes(tooltip.attributes),
tooltip.buff && (<p className={styles.buff} dangerouslySetInnerHTML={{ __html: tooltip.buff }}/>),
renderConsumable(tooltip.consumable),
Expand Down Expand Up @@ -155,13 +175,26 @@ export const ClientItemTooltip: FC<ClientItemTooltipProps> = ({ tooltip }) => {
...tooltip.flags,
tooltip.value && (<Coins value={tooltip.value}/>),
];
}

export function renderWeaponStrength({ weaponStrength }: ItemTooltip) {
if(!weaponStrength) {
return;
}

return (
<div>
{data.filter(isTruthy).map((content, index) => {
// eslint-disable-next-line react/no-array-index-key
return <div className={styles.row} key={index}>{content}</div>;
})}
</div>
<>
{weaponStrength.label}: <FormatNumber value={weaponStrength.min} className={styles.value}/> – <FormatNumber value={weaponStrength.max} className={styles.value}/>
</>
);
};
}

export function renderDefense({ defense }: ItemTooltip) {
if(!defense) {
return;
}

return (
<>{defense.label}: <FormatNumber value={defense.value} className={styles.value}/></>
);
}
10 changes: 10 additions & 0 deletions apps/web/components/Item/ItemTooltip.module.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
.title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;

font-family: var(--font-bitter);
color: var(--color-rarity);
}

.row + .row {
margin-top: 8px;
}
Expand Down
11 changes: 9 additions & 2 deletions apps/web/components/Item/ItemTooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@ import type { RGB } from '../Color/types';
export interface ItemTooltipProps {
item: Gw2Api.Item;
language: Language;
hideTitle?: boolean;
}

export const ItemTooltip: AsyncComponent<ItemTooltipProps> = async ({ item, language }) => {
export const ItemTooltip: AsyncComponent<ItemTooltipProps> = async ({ item, language, hideTitle }) => {
const tooltip = await createTooltip(item, language);

return (
<ClientItemTooltip tooltip={tooltip}/>
<ClientItemTooltip tooltip={tooltip} hideTitle={hideTitle}/>
);
};

Expand Down Expand Up @@ -65,8 +66,12 @@ export async function createTooltip(item: Gw2Api.Item, language: Language): Prom
? await db.color.findUnique({ where: { id: item.details.color_id }})
: null;

const icon = parseIcon(item.icon);

return {
language,
name: item.name,
icon,
weaponStrength: item.type === 'Weapon' ? { label: 'Strength', min: item.details?.min_power ?? 0, max: item.details?.max_power ?? 0 } : undefined,
defense: item.type === 'Armor' ? { label: 'Defense', value: item.details?.defense ?? 0 } : undefined,
attributes: item.details?.infix_upgrade?.attributes && item.details.infix_upgrade.attributes.length > 0 ? item.details.infix_upgrade.attributes.map((({ attribute, modifier }) => ({ label: t(`attribute.${attribute}`), value: modifier }))) : undefined,
Expand Down Expand Up @@ -121,6 +126,8 @@ export type ItemWithAttributes = WithIcon<LocalizedEntity> & {

export interface ItemTooltip {
language: Language,
name: string,
icon?: { id: number, signature: string },
weaponStrength?: { label: string, min: number, max: number },
defense?: { label: string, value: number },
attributes?: { label: string, value: number }[],
Expand Down
4 changes: 4 additions & 0 deletions apps/web/components/Layout/DiffLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ interface DiffLayoutRowProps {
};

export const DiffLayoutRow: FC<DiffLayoutRowProps> = ({ left, right, changed = false }) => {
if(!left && !right) {
return null;
}

return (
<div className={cx(styles.diffRow, !left && styles.added, !right && styles.removed, left && right && changed && styles.changed)}>
<div className={styles.left}>{left}</div>
Expand Down
Loading