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:
Ilia Mashkov
2026-05-19 18:06:20 +03:00
parent 41af0b90a0
commit d0f09f0dbd
4 changed files with 64 additions and 7 deletions
+10 -2
View File
@@ -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
*/ */
+1
View File
@@ -0,0 +1 @@
export { InlineSvg } from './ui/InlineSvg';
+25
View File
@@ -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>;
}
+28 -5
View File
@@ -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: [],
},
}, },
}, },
}); });