feat: social links with inline SVG icons from CMS
SocialRecord gains icon field (SVG markup string). InlineSvg component parses SVG string via html-react-parser. Footer renders icon on mobile (sm:hidden label), label on sm+ (hidden icon). Email field refactored from string to SocialRecord relation.
This commit is contained in:
+10
-2
@@ -141,6 +141,10 @@ export type SocialRecord = BaseRecord & {
|
|||||||
* Full URL for the social profile
|
* Full URL for the social profile
|
||||||
*/
|
*/
|
||||||
url: string;
|
url: string;
|
||||||
|
/**
|
||||||
|
* SVG markup string stored in PocketBase
|
||||||
|
*/
|
||||||
|
icon: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -149,7 +153,7 @@ export type SocialRecord = BaseRecord & {
|
|||||||
*/
|
*/
|
||||||
export type ContactsRecord = BaseRecord & {
|
export type ContactsRecord = BaseRecord & {
|
||||||
/**
|
/**
|
||||||
* Primary contact email address
|
* Raw relation ID — use expand?.email for the resolved record
|
||||||
*/
|
*/
|
||||||
email: string;
|
email: string;
|
||||||
/**
|
/**
|
||||||
@@ -157,9 +161,13 @@ export type ContactsRecord = BaseRecord & {
|
|||||||
*/
|
*/
|
||||||
socials: string[];
|
socials: string[];
|
||||||
/**
|
/**
|
||||||
* Expanded relation data, present when fetched with expand=socials
|
* Expanded relation data, present when fetched with expand=email,socials
|
||||||
*/
|
*/
|
||||||
expand?: {
|
expand?: {
|
||||||
|
/**
|
||||||
|
* Resolved email contact record
|
||||||
|
*/
|
||||||
|
email?: SocialRecord;
|
||||||
/**
|
/**
|
||||||
* Resolved social link records
|
* Resolved social link records
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { InlineSvg } from './ui/InlineSvg';
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import parse from 'html-react-parser';
|
||||||
|
import { cn } from '$shared/lib';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* SVG markup string to inline as React elements
|
||||||
|
*/
|
||||||
|
svg: string;
|
||||||
|
/**
|
||||||
|
* Additional CSS classes on the wrapper span
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an SVG markup string into React elements.
|
||||||
|
* Inherits color from parent via currentColor.
|
||||||
|
*/
|
||||||
|
export function InlineSvg({ svg, className }: Props) {
|
||||||
|
if (!svg) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className={cn('inline-flex items-center', className)}>{parse(svg)}</span>;
|
||||||
|
}
|
||||||
@@ -21,9 +21,19 @@ const mockSettings = {
|
|||||||
collectionName: 'contacts',
|
collectionName: 'contacts',
|
||||||
created: '',
|
created: '',
|
||||||
updated: '',
|
updated: '',
|
||||||
email: 'hello@allmy.work',
|
email: 'e1',
|
||||||
socials: ['s1'],
|
socials: ['s1'],
|
||||||
expand: {
|
expand: {
|
||||||
|
email: {
|
||||||
|
id: 'e1',
|
||||||
|
collectionId: 'contact',
|
||||||
|
collectionName: 'contact',
|
||||||
|
created: '',
|
||||||
|
updated: '',
|
||||||
|
label: 'hello@allmy.work',
|
||||||
|
url: 'mailto:hello@allmy.work',
|
||||||
|
icon: '',
|
||||||
|
},
|
||||||
socials: [
|
socials: [
|
||||||
{
|
{
|
||||||
id: 's1',
|
id: 's1',
|
||||||
@@ -33,6 +43,7 @@ const mockSettings = {
|
|||||||
updated: '',
|
updated: '',
|
||||||
label: 'GitHub',
|
label: 'GitHub',
|
||||||
url: 'https://github.com',
|
url: 'https://github.com',
|
||||||
|
icon: '',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -58,19 +69,28 @@ describe('Footer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('email link', () => {
|
describe('email link', () => {
|
||||||
it('renders the contact email as a mailto link', async () => {
|
it('renders the contact email link', async () => {
|
||||||
|
render(await Footer());
|
||||||
|
const link = screen.getByRole('link', { name: /hello@allmy\.work/i });
|
||||||
|
expect(link).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('email link points to the mailto url', async () => {
|
||||||
render(await Footer());
|
render(await Footer());
|
||||||
const link = screen.getByRole('link', { name: /hello@allmy\.work/i });
|
const link = screen.getByRole('link', { name: /hello@allmy\.work/i });
|
||||||
expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work');
|
expect(link).toHaveAttribute('href', 'mailto:hello@allmy.work');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not render email link when contacts.email is missing', async () => {
|
it('does not render email link when expand.email is missing', async () => {
|
||||||
vi.mocked(getFirstRecord).mockResolvedValue({
|
vi.mocked(getFirstRecord).mockResolvedValue({
|
||||||
...mockSettings,
|
...mockSettings,
|
||||||
expand: {
|
expand: {
|
||||||
contacts: {
|
contacts: {
|
||||||
...mockSettings.expand.contacts,
|
...mockSettings.expand.contacts,
|
||||||
email: '',
|
expand: {
|
||||||
|
...mockSettings.expand.contacts.expand,
|
||||||
|
email: undefined,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -98,7 +118,10 @@ describe('Footer', () => {
|
|||||||
expand: {
|
expand: {
|
||||||
contacts: {
|
contacts: {
|
||||||
...mockSettings.expand.contacts,
|
...mockSettings.expand.contacts,
|
||||||
expand: { socials: [] },
|
expand: {
|
||||||
|
...mockSettings.expand.contacts.expand,
|
||||||
|
socials: [],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user