SEO & AEO: everything from the PDF review (robots, sitemap, 404, meta/OG, JSON-LD, Swedish text, /tjanster, EXIF clean, alt texts, Admin@, Örebro) #1
Binary file not shown.
|
Before Width: | Height: | Size: 700 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 542 KiB |
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
// src/components/SEO.astro
|
||||||
|
// <title>, meta description, canonical, Open Graph och Twitter-kort för en sida.
|
||||||
|
import { business } from '../data/business';
|
||||||
|
import { normalizePath } from '../lib/url';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
/** Sökväg utan avslutande snedstreck, t.ex. "/about". Annars härleds den från URL:en. */
|
||||||
|
path?: string;
|
||||||
|
/** Absolut URL till og:image (MainLayout tar fram en EXIF-fri JPG via getImage). */
|
||||||
|
imageUrl: string;
|
||||||
|
imageWidth?: number;
|
||||||
|
imageHeight?: number;
|
||||||
|
imageAlt: string;
|
||||||
|
type?: 'website' | 'profile' | 'article';
|
||||||
|
noindex?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description, path, imageUrl, imageWidth, imageHeight, imageAlt, type = 'website', noindex = false } = Astro.props;
|
||||||
|
|
||||||
|
const site = Astro.site ?? new URL(business.url);
|
||||||
|
const canonical = new URL(normalizePath(path ?? Astro.url.pathname), site).toString();
|
||||||
|
---
|
||||||
|
|
||||||
|
<title>{title}</title>
|
||||||
|
<meta name="description" content={description} />
|
||||||
|
{noindex ? <meta name="robots" content="noindex, follow" /> : <link rel="canonical" href={canonical} />}
|
||||||
|
|
||||||
|
<meta property="og:type" content={type} />
|
||||||
|
<meta property="og:site_name" content={business.brand} />
|
||||||
|
<meta property="og:locale" content="sv_SE" />
|
||||||
|
<meta property="og:title" content={title} />
|
||||||
|
<meta property="og:description" content={description} />
|
||||||
|
<meta property="og:url" content={canonical} />
|
||||||
|
<meta property="og:image" content={imageUrl} />
|
||||||
|
{imageWidth && <meta property="og:image:width" content={String(imageWidth)} />}
|
||||||
|
{imageHeight && <meta property="og:image:height" content={String(imageHeight)} />}
|
||||||
|
<meta property="og:image:alt" content={imageAlt} />
|
||||||
|
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<meta name="twitter:title" content={title} />
|
||||||
|
<meta name="twitter:description" content={description} />
|
||||||
|
<meta name="twitter:image" content={imageUrl} />
|
||||||
|
<meta name="twitter:image:alt" content={imageAlt} />
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
---
|
||||||
|
// src/components/SchemaOrg.astro
|
||||||
|
// JSON-LD (@graph): WebSite + Person (Linus) + ProfessionalService (Images by Olofsson)
|
||||||
|
// + WebPage för aktuell sida (+ FAQPage och BreadcrumbList när det är relevant).
|
||||||
|
// Allt hämtas från src/data/business.ts. null-värden utelämnas helt.
|
||||||
|
import { business, absoluteUrl, genreList } from '../data/business';
|
||||||
|
import type { FaqItem } from '../data/faq';
|
||||||
|
import { normalizePath } from '../lib/url';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
path?: string;
|
||||||
|
pageType?: 'WebPage' | 'AboutPage' | 'ContactPage' | 'CollectionPage';
|
||||||
|
/** Namn i brödsmulan för undersidor, t.ex. "Tjänster". */
|
||||||
|
breadcrumb?: string;
|
||||||
|
faq?: FaqItem[];
|
||||||
|
/** Absolut URL till sidans huvudbild (og:image). */
|
||||||
|
imageUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, description, path, pageType = 'WebPage', breadcrumb, faq, imageUrl } = Astro.props;
|
||||||
|
|
||||||
|
const site = (Astro.site ?? new URL(business.url)).toString().replace(/\/$/, '');
|
||||||
|
const pagePath = normalizePath(path ?? Astro.url.pathname);
|
||||||
|
const pageUrl = absoluteUrl(pagePath);
|
||||||
|
|
||||||
|
const ids = {
|
||||||
|
website: `${site}/#website`,
|
||||||
|
person: `${site}/#linus-olofsson`,
|
||||||
|
business: `${site}/#images-by-olofsson`,
|
||||||
|
webpage: `${pageUrl}#webpage`,
|
||||||
|
breadcrumb: `${pageUrl}#breadcrumb`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tar bort null/undefined/tomma listor så att okända fakta aldrig hamnar i JSON-LD.
|
||||||
|
function clean<T>(value: T): T {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(clean).filter((v) => v !== null && v !== undefined) as unknown as T;
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
if (v === null || v === undefined) continue;
|
||||||
|
const c = clean(v);
|
||||||
|
if (Array.isArray(c) && c.length === 0) continue;
|
||||||
|
out[k] = c;
|
||||||
|
}
|
||||||
|
return out as T;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Landet är känt (Sverige); ort och region läggs till när de fylls i business.ts.
|
||||||
|
const address = {
|
||||||
|
'@type': 'PostalAddress',
|
||||||
|
addressLocality: business.city,
|
||||||
|
addressRegion: business.region,
|
||||||
|
addressCountry: business.country.code,
|
||||||
|
};
|
||||||
|
|
||||||
|
const graph: Record<string, unknown>[] = [
|
||||||
|
{
|
||||||
|
'@type': 'WebSite',
|
||||||
|
'@id': ids.website,
|
||||||
|
url: `${site}/`,
|
||||||
|
name: business.brand,
|
||||||
|
inLanguage: 'sv-SE',
|
||||||
|
publisher: { '@id': ids.business },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Person',
|
||||||
|
'@id': ids.person,
|
||||||
|
name: business.person.name,
|
||||||
|
jobTitle: business.person.jobTitle,
|
||||||
|
url: absoluteUrl('/about'),
|
||||||
|
description: `${business.person.jobTitle} baserad i ${business.country.name}. ${business.recognitionThirdPerson}`,
|
||||||
|
knowsAbout: business.genres.map((g) => g.name),
|
||||||
|
worksFor: { '@id': ids.business },
|
||||||
|
sameAs: [business.social.linkedin, business.social.lightroom],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'ProfessionalService',
|
||||||
|
'@id': ids.business,
|
||||||
|
name: business.brand,
|
||||||
|
url: `${site}/`,
|
||||||
|
description: `${business.person.name} fotograferar ${genreList()}.`,
|
||||||
|
email: business.email,
|
||||||
|
telephone: business.phone,
|
||||||
|
image: imageUrl,
|
||||||
|
address,
|
||||||
|
areaServed: business.serviceArea,
|
||||||
|
priceRange: business.priceRange,
|
||||||
|
founder: { '@id': ids.person },
|
||||||
|
employee: { '@id': ids.person },
|
||||||
|
sameAs: [business.social.instagram.url, business.social.flickr, business.social.lightroom],
|
||||||
|
potentialAction: {
|
||||||
|
'@type': 'ReserveAction',
|
||||||
|
name: 'Skicka en bokningsförfrågan',
|
||||||
|
target: business.portal.bookingUrl,
|
||||||
|
},
|
||||||
|
hasOfferCatalog: {
|
||||||
|
'@type': 'OfferCatalog',
|
||||||
|
name: 'Fotografering',
|
||||||
|
itemListElement: business.genres.map((g) => ({
|
||||||
|
'@type': 'Offer',
|
||||||
|
priceCurrency: business.priceFromSek != null ? 'SEK' : null,
|
||||||
|
price: business.priceFromSek,
|
||||||
|
itemOffered: {
|
||||||
|
'@type': 'Service',
|
||||||
|
name: g.name,
|
||||||
|
description: g.description,
|
||||||
|
provider: { '@id': ids.business },
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const webpage: Record<string, unknown> = {
|
||||||
|
'@type': faq?.length ? [pageType, 'FAQPage'] : pageType,
|
||||||
|
'@id': ids.webpage,
|
||||||
|
url: pageUrl,
|
||||||
|
name: title,
|
||||||
|
description,
|
||||||
|
inLanguage: 'sv-SE',
|
||||||
|
isPartOf: { '@id': ids.website },
|
||||||
|
about: { '@id': ids.business },
|
||||||
|
primaryImageOfPage: imageUrl ? { '@type': 'ImageObject', url: imageUrl } : null,
|
||||||
|
breadcrumb: breadcrumb ? { '@id': ids.breadcrumb } : null,
|
||||||
|
mainEntity: faq?.length
|
||||||
|
? faq.map((item) => ({
|
||||||
|
'@type': 'Question',
|
||||||
|
name: item.question,
|
||||||
|
acceptedAnswer: { '@type': 'Answer', text: item.answer },
|
||||||
|
}))
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
graph.push(webpage);
|
||||||
|
|
||||||
|
if (breadcrumb) {
|
||||||
|
graph.push({
|
||||||
|
'@type': 'BreadcrumbList',
|
||||||
|
'@id': ids.breadcrumb,
|
||||||
|
itemListElement: [
|
||||||
|
{ '@type': 'ListItem', position: 1, name: 'Hem', item: `${site}/` },
|
||||||
|
{ '@type': 'ListItem', position: 2, name: breadcrumb, item: pageUrl },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = JSON.stringify(clean({ '@context': 'https://schema.org', '@graph': graph })).replace(/</g, '\\u003c');
|
||||||
|
---
|
||||||
|
|
||||||
|
<script type="application/ld+json" is:inline set:html={json} />
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// src/data/business.ts
|
||||||
|
// ALLA fakta om Images by Olofsson på ett ställe. Sidor, JSON-LD, llms.txt och
|
||||||
|
// sitemap läser härifrån – skriv aldrig in fakta direkt i en sida.
|
||||||
|
//
|
||||||
|
// Regel: det vi inte vet är `null`. Allt som läser filen hoppar över null-värden
|
||||||
|
// (ingen "TODO", inga påhittade siffror syns på sajten). När Linus lämnar ett
|
||||||
|
// värde: fyll i det här, så dyker det upp på /tjanster, i FAQ och i JSON-LD.
|
||||||
|
|
||||||
|
export type Genre = {
|
||||||
|
/** Kort id, används som ankare på /tjanster (#portratt osv.) */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Business = {
|
||||||
|
brand: string;
|
||||||
|
url: string;
|
||||||
|
person: {
|
||||||
|
name: string;
|
||||||
|
jobTitle: string;
|
||||||
|
};
|
||||||
|
email: string;
|
||||||
|
/** Telefonnummer i format +46... – okänt */
|
||||||
|
phone: string | null;
|
||||||
|
country: { code: string; name: string };
|
||||||
|
/** Ort Linus utgår från – okänt */
|
||||||
|
city: string | null;
|
||||||
|
/** Län/region – okänt */
|
||||||
|
region: string | null;
|
||||||
|
/** Område han tar uppdrag i (fritext, t.ex. "Stockholm med omnejd") – okänt */
|
||||||
|
serviceArea: string | null;
|
||||||
|
/** Lägsta pris i SEK för en fotografering – okänt */
|
||||||
|
priceFromSek: number | null;
|
||||||
|
/** schema.org priceRange, t.ex. "$$" eller "1500–5000 kr" – okänt */
|
||||||
|
priceRange: string | null;
|
||||||
|
/** Paket och vad som ingår – okänt */
|
||||||
|
packages: { name: string; includes: string[]; priceSek: number | null }[] | null;
|
||||||
|
/** Typisk leveranstid, fritext (t.ex. "inom två veckor") – okänt */
|
||||||
|
deliveryTime: string | null;
|
||||||
|
/** Antal år som fotograf – okänt */
|
||||||
|
yearsOfExperience: number | null;
|
||||||
|
/** Kundomdömen – inga kända */
|
||||||
|
testimonials: { name: string; text: string }[] | null;
|
||||||
|
camera: string;
|
||||||
|
lenses: string[];
|
||||||
|
editing: string;
|
||||||
|
/** Linus egen formulering: "featured by Adobe three times". Inga datum/detaljer. Jag-form för sidtext. */
|
||||||
|
recognition: string;
|
||||||
|
/** Samma fakta i tredje person (llms.txt, JSON-LD, meta descriptions). */
|
||||||
|
recognitionThirdPerson: string;
|
||||||
|
portal: {
|
||||||
|
url: string;
|
||||||
|
bookingUrl: string;
|
||||||
|
};
|
||||||
|
social: {
|
||||||
|
instagram: { handle: string; url: string };
|
||||||
|
flickr: string;
|
||||||
|
lightroom: string;
|
||||||
|
linkedin: string;
|
||||||
|
github: string;
|
||||||
|
};
|
||||||
|
genres: Genre[];
|
||||||
|
bookingSteps: { title: string; text: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const business: Business = {
|
||||||
|
brand: 'Images by Olofsson',
|
||||||
|
url: 'https://imagesbyolofsson.se',
|
||||||
|
person: {
|
||||||
|
name: 'Linus Olofsson',
|
||||||
|
jobTitle: 'Fotograf',
|
||||||
|
},
|
||||||
|
email: 'info@imagesbyolofsson.se',
|
||||||
|
phone: null,
|
||||||
|
country: { code: 'SE', name: 'Sverige' },
|
||||||
|
city: null,
|
||||||
|
region: null,
|
||||||
|
serviceArea: null,
|
||||||
|
priceFromSek: null,
|
||||||
|
priceRange: null,
|
||||||
|
packages: null,
|
||||||
|
deliveryTime: null,
|
||||||
|
yearsOfExperience: null,
|
||||||
|
testimonials: null,
|
||||||
|
camera: 'Sony A7R V',
|
||||||
|
lenses: ['Sony FE 70-200mm f/4 G OSS', 'Sony FE 20mm f/1.8 G'],
|
||||||
|
editing: 'Adobe Lightroom Classic',
|
||||||
|
recognition: 'Mina bilder har lyfts fram av Adobe tre gånger.',
|
||||||
|
recognitionThirdPerson: 'Hans bilder har lyfts fram av Adobe tre gånger.',
|
||||||
|
portal: {
|
||||||
|
url: 'https://customer.imagesbyolofsson.se',
|
||||||
|
bookingUrl: 'https://customer.imagesbyolofsson.se/boka',
|
||||||
|
},
|
||||||
|
social: {
|
||||||
|
instagram: { handle: '@imagesbyolofsson', url: 'https://www.instagram.com/imagesbyolofsson/' },
|
||||||
|
flickr: 'https://www.flickr.com/photos/imagesbyolofsson/',
|
||||||
|
lightroom: 'https://lightroom.adobe.com/u/linusolofsson1',
|
||||||
|
linkedin: 'https://www.linkedin.com/in/linus-olofsson-/',
|
||||||
|
github: 'https://github.com/LinusOlofsson-maker',
|
||||||
|
},
|
||||||
|
// Bara genrer som redan finns i sajtens texter och gamla sidnamn
|
||||||
|
// (/my-people, /animales-and-nature, /celebrations-with-family, /northen-lights).
|
||||||
|
genres: [
|
||||||
|
{
|
||||||
|
id: 'portratt',
|
||||||
|
name: 'Porträtt och människor',
|
||||||
|
description: 'Porträtt och ögonblick med människor – uttryck som berättar något utan ord.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'natur',
|
||||||
|
name: 'Natur och djur',
|
||||||
|
description: 'Skog, landskap och djur – naturens stillhet och de små detaljerna i den.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stad',
|
||||||
|
name: 'Stad och arkitektur',
|
||||||
|
description: 'Gator, byggnader, strukturer och kontraster – stadens energi och dess tomma stunder.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'makro',
|
||||||
|
name: 'Makro och detaljer',
|
||||||
|
description: 'Mönster, texturer och små detaljer som de flesta går förbi.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'norrsken',
|
||||||
|
name: 'Norrsken',
|
||||||
|
description: 'Norrskenets ljus och färger över himlen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fest',
|
||||||
|
name: 'Fester och familjehögtider',
|
||||||
|
description: 'Bilder från fester och högtider tillsammans med familj och vänner.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
bookingSteps: [
|
||||||
|
{
|
||||||
|
title: 'Skicka en förfrågan',
|
||||||
|
text: 'Berätta vad du vill ha fotograferat, när och var – via kundportalen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Få en offert',
|
||||||
|
text: 'Jag återkommer med ett förslag och en offert för just ditt uppdrag.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Bokning',
|
||||||
|
text: 'När du har godkänt offerten bokar vi in fotograferingen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Leverans i ett privat galleri',
|
||||||
|
text: 'Du får bilderna i ett eget, privat galleri i kundportalen.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Originalfilerna efter betalning',
|
||||||
|
text: 'När betalningen är klar kan du ladda ner originalfilerna i full upplösning.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Genrer som läsbar lista: "a, b, c samt d". */
|
||||||
|
export function genreList(lowercase = true): string {
|
||||||
|
const names = business.genres.map((g) => (lowercase ? g.name.toLowerCase() : g.name));
|
||||||
|
if (names.length <= 1) return names.join('');
|
||||||
|
// "samt" före sista ledet, eftersom genrenamnen själva innehåller "och".
|
||||||
|
return `${names.slice(0, -1).join(', ')} samt ${names[names.length - 1]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Sverige", eller "Ort, Sverige" när orten är känd. */
|
||||||
|
export function basedIn(): string {
|
||||||
|
return [business.city, business.country.name].filter(Boolean).join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formaterat pris ("1 500 kr") eller null. */
|
||||||
|
export function formatSek(value: number | null): string | null {
|
||||||
|
if (value == null) return null;
|
||||||
|
return `${value.toLocaleString('sv-SE')} kr`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function absoluteUrl(path: string): string {
|
||||||
|
return new URL(path, business.url).toString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// src/data/faq.ts
|
||||||
|
// Vanliga frågor för /tjanster. Samma text används på sidan och i FAQPage-JSON-LD.
|
||||||
|
// Svaren byggs ENBART av fakta i business.ts. Okända fakta (null) ger ett
|
||||||
|
// "kontakta mig"-svar i stället för en gissning.
|
||||||
|
import { business, basedIn, formatSek, genreList } from './business';
|
||||||
|
|
||||||
|
export type FaqItem = { question: string; answer: string };
|
||||||
|
|
||||||
|
function areaAnswer(): string {
|
||||||
|
if (business.serviceArea) {
|
||||||
|
return `Jag utgår från ${basedIn()} och tar uppdrag i ${business.serviceArea}. Hör av dig om du är osäker på om jag kan komma till dig.`;
|
||||||
|
}
|
||||||
|
return `Jag är baserad i ${basedIn()}. Kontakta mig så stämmer vi av om jag kan fotografera där du befinner dig.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function priceAnswer(): string {
|
||||||
|
const from = formatSek(business.priceFromSek);
|
||||||
|
if (from) {
|
||||||
|
return `En fotografering kostar från ${from}. Det slutliga priset beror på uppdraget och står i offerten du får efter din förfrågan.`;
|
||||||
|
}
|
||||||
|
return 'Priset beror på uppdraget. Kontakta mig eller skicka en förfrågan i kundportalen, så får du en offert för just det du vill ha fotograferat.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function deliveryAnswer(): string {
|
||||||
|
const base = 'Du får bilderna i ett privat galleri i kundportalen. När betalningen är klar kan du ladda ner originalfilerna i full upplösning.';
|
||||||
|
if (business.deliveryTime) return `${base} Normal leveranstid är ${business.deliveryTime}.`;
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const faq: FaqItem[] = [
|
||||||
|
{
|
||||||
|
question: 'Vad fotograferar du?',
|
||||||
|
answer: `Jag fotograferar ${genreList()}.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Var finns du och var kan du fotografera?',
|
||||||
|
answer: areaAnswer(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Vad kostar en fotografering?',
|
||||||
|
answer: priceAnswer(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Hur bokar jag en fotografering?',
|
||||||
|
answer: `Skicka en förfrågan i kundportalen (${business.portal.bookingUrl}). Jag återkommer med en offert, och när du har godkänt den bokar vi in fotograferingen.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Hur får jag mina bilder?',
|
||||||
|
answer: deliveryAnswer(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Vilken utrustning använder du?',
|
||||||
|
answer: `Jag fotograferar med en ${business.camera} och redigerar i ${business.editing}.`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Har dina bilder uppmärksammats?',
|
||||||
|
answer: business.recognition,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
question: 'Hur kontaktar jag dig?',
|
||||||
|
answer: `Mejla ${business.email} eller skicka ett meddelande på Instagram (${business.social.instagram.handle}).`,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// src/data/portfolio-meta.ts
|
||||||
|
// DATAKROK för riktiga alt-texter (och ev. titel/kategori) per portfoliobild.
|
||||||
|
// Fylls i senare (t.ex. av Imogen-agenten). Tom post = sidan använder en
|
||||||
|
// neutral svensk reservtext byggd på datum, aldrig kameramodell eller "undefined".
|
||||||
|
//
|
||||||
|
// Nyckel = bildens filnamn utan ändelse, exakt som i src/assets/images/portfolio/,
|
||||||
|
// t.ex. "immich-02533d10-07ca-4a39-8b8a-6648fe2ffcef-20260905-DSC07734"
|
||||||
|
// eller "20250510-WJ9E1439-Enhanced-NR".
|
||||||
|
//
|
||||||
|
// Exempel:
|
||||||
|
// "immich-02533d10-...-20260905-DSC07734": {
|
||||||
|
// alt: "Norrsken i grönt och lila över en mörk skogssiluett",
|
||||||
|
// title: "Norrsken i september",
|
||||||
|
// category: "Norrsken",
|
||||||
|
// },
|
||||||
|
|
||||||
|
export type PortfolioMeta = {
|
||||||
|
/** Beskriv vad bilden visar, på svenska, ca 8–16 ord. */
|
||||||
|
alt?: string;
|
||||||
|
/** Kort bildtitel (visas i lightboxen om den finns). */
|
||||||
|
title?: string;
|
||||||
|
/** Helst ett av namnen i business.genres, t.ex. "Natur och djur". */
|
||||||
|
category?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const portfolioMeta: Record<string, PortfolioMeta> = {};
|
||||||
@@ -1,56 +1,127 @@
|
|||||||
---
|
---
|
||||||
import "../styles/global.css";
|
import "../styles/global.css";
|
||||||
const { title } = Astro.props;
|
import type { ImageMetadata } from 'astro';
|
||||||
|
import { getImage } from 'astro:assets';
|
||||||
|
import SEO from '../components/SEO.astro';
|
||||||
|
import SchemaOrg from '../components/SchemaOrg.astro';
|
||||||
|
import type { FaqItem } from '../data/faq';
|
||||||
|
import { business } from '../data/business';
|
||||||
|
import defaultOgImage from '../assets/images/3d-scroll/img5.jpg';
|
||||||
|
|
||||||
|
export interface Props {
|
||||||
|
/** Unik svensk titel för sidan. */
|
||||||
|
title: string;
|
||||||
|
/** Unik svensk meta description, ca 120–160 tecken. */
|
||||||
|
description: string;
|
||||||
|
/** Kanonisk sökväg, t.ex. "/tjanster". Utelämnas = härleds från URL:en. */
|
||||||
|
path?: string;
|
||||||
|
image?: ImageMetadata;
|
||||||
|
imageAlt?: string;
|
||||||
|
pageType?: 'WebPage' | 'AboutPage' | 'ContactPage' | 'CollectionPage';
|
||||||
|
/** Namn i brödsmulan (BreadcrumbList) för undersidor. */
|
||||||
|
breadcrumb?: string;
|
||||||
|
faq?: FaqItem[];
|
||||||
|
noindex?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
path,
|
||||||
|
image = defaultOgImage,
|
||||||
|
imageAlt = 'Svartvit bild av en man som bär ett litet barn bland såpbubblor i motljus',
|
||||||
|
pageType,
|
||||||
|
breadcrumb,
|
||||||
|
faq,
|
||||||
|
noindex = false,
|
||||||
|
} = Astro.props;
|
||||||
|
|
||||||
|
// og:image: ny JPG via Astros bildtjänst (EXIF/GPS rensas bort), absolut URL.
|
||||||
|
const site = Astro.site ?? new URL(business.url);
|
||||||
|
const og = await getImage({ src: image, width: 1200, format: 'jpg', quality: 82 });
|
||||||
|
const ogImageUrl = new URL(og.src, site).toString();
|
||||||
|
const ogWidth = Number(og.attributes.width) || undefined;
|
||||||
|
const ogHeight = Number(og.attributes.height) || undefined;
|
||||||
|
|
||||||
|
const nav = [
|
||||||
|
{ href: '/portfolio', label: 'Portfolio' },
|
||||||
|
{ href: '/tjanster', label: 'Tjänster' },
|
||||||
|
{ href: '/about', label: 'Om mig' },
|
||||||
|
{ href: '/contact', label: 'Kontakt' },
|
||||||
|
];
|
||||||
---
|
---
|
||||||
|
|
||||||
<html lang="sv" class="scroll-smooth"> <head>
|
<!doctype html>
|
||||||
|
<html lang="sv" class="scroll-smooth">
|
||||||
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#171717" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
|
||||||
<link rel="icon" type="image/png" sizes="512x512" href="/favicon-512.png" />
|
<link rel="icon" type="image/png" sizes="512x512" href="/favicon-512.png" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon-180.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/favicon-180.png" />
|
||||||
<title>{title}</title>
|
<link rel="sitemap" type="application/xml" href="/sitemap-index.xml" />
|
||||||
|
<SEO
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
path={path}
|
||||||
|
imageUrl={ogImageUrl}
|
||||||
|
imageWidth={ogWidth}
|
||||||
|
imageHeight={ogHeight}
|
||||||
|
imageAlt={imageAlt}
|
||||||
|
noindex={noindex}
|
||||||
|
/>
|
||||||
|
{!noindex && (
|
||||||
|
<SchemaOrg
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
path={path}
|
||||||
|
pageType={pageType}
|
||||||
|
breadcrumb={breadcrumb}
|
||||||
|
faq={faq}
|
||||||
|
imageUrl={ogImageUrl}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-neutral-900 text-white font-sans antialiased min-h-screen flex flex-col">
|
<body class="bg-neutral-900 text-white font-sans antialiased min-h-screen flex flex-col">
|
||||||
|
|
||||||
<nav class="fixed top-0 w-full z-50 border-b border-white/10 py-6 bg-neutral-900/80 backdrop-blur-md transition-all duration-300">
|
<nav class="fixed top-0 w-full z-50 border-b border-white/10 py-4 md:py-6 bg-neutral-900/80 backdrop-blur-md transition-all duration-300">
|
||||||
<div class="container mx-auto px-4 flex justify-between items-center">
|
<div class="container mx-auto px-4 flex flex-wrap gap-y-2 justify-between items-center">
|
||||||
|
|
||||||
<a href="/" class="text-2xl font-bold tracking-wider uppercase hover:text-gray-300 transition relative z-50">
|
<a href="/" class="text-lg md:text-2xl font-bold tracking-wider uppercase hover:text-gray-300 transition relative z-50">
|
||||||
Images by Olofsson
|
Images by Olofsson
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<ul class="flex space-x-6 text-sm font-medium text-gray-400">
|
<ul class="flex space-x-4 md:space-x-6 text-sm font-medium text-gray-300">
|
||||||
<li><a href="/portfolio" class="hover:text-white transition">Portfolio</a></li>
|
{nav.map((item) => (
|
||||||
<li><a href="/about" class="hover:text-white transition">About</a></li>
|
<li><a href={item.href} class="hover:text-white transition">{item.label}</a></li>
|
||||||
<li><a href="/contact" class="hover:text-white transition">Contact</a></li>
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main class="flex-grow pt-24">
|
<main class="flex-grow pt-28 md:pt-24">
|
||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="border-t border-neutral-800 py-8 text-center text-gray-500 text-sm bg-neutral-900 relative z-40">
|
<footer class="border-t border-neutral-800 py-8 text-center text-gray-400 text-sm bg-neutral-900 relative z-40">
|
||||||
<p class="mb-2">
|
<p class="mb-2">
|
||||||
Crafting digital experiences for
|
{business.brand} – {business.person.jobTitle.toLowerCase()} {business.person.name}, {business.country.name}
|
||||||
<a href="https://lyxlimo.se/" target="_blank" rel="noopener noreferrer" data-glow-link="green" class="text-gray-400 hover:text-white transition-colors">lyxlimo.se</a>
|
· <a href={`mailto:${business.email}`} class="text-gray-300 hover:text-white transition-colors">{business.email}</a>
|
||||||
&
|
· <a href={business.social.instagram.url} target="_blank" rel="noopener noreferrer" class="text-gray-300 hover:text-white transition-colors">Instagram</a>
|
||||||
<a href="https://karleksguiden.se/" target="_blank" rel="noopener noreferrer" data-glow-link="magenta" class="text-gray-400 hover:text-white transition-colors">karleksguiden.se</a>
|
|
||||||
</p>
|
</p>
|
||||||
<p>© {new Date().getFullYear()} Images by Olofsson. All rights reserved. · <a href="/privacy" class="text-gray-400 hover:text-white transition-colors">Privacy</a> · <button type="button" data-consent-open class="text-gray-400 hover:text-white transition-colors">Cookie settings</button></p>
|
<p>© {new Date().getFullYear()} {business.brand}. Alla rättigheter förbehållna. · <a href="/privacy" class="text-gray-300 hover:text-white transition-colors">Integritetspolicy</a> · <button type="button" data-consent-open class="text-gray-300 hover:text-white transition-colors">Cookie-inställningar</button></p>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<!-- Cookie consent: Meta Pixel laddas forst efter "Accept" -->
|
<!-- Cookie consent: Meta Pixel laddas först efter "Godkänn" -->
|
||||||
<div data-consent-banner hidden class="fixed bottom-4 inset-x-4 md:left-auto md:right-6 md:max-w-md z-[60] bg-neutral-900/95 backdrop-blur-md border border-white/10 rounded-2xl p-5 shadow-2xl text-sm text-gray-300">
|
<div data-consent-banner hidden class="fixed bottom-4 inset-x-4 md:left-auto md:right-6 md:max-w-md z-[60] bg-neutral-900/95 backdrop-blur-md border border-white/10 rounded-2xl p-5 shadow-2xl text-sm text-gray-300">
|
||||||
<p class="mb-4">
|
<p class="mb-4">
|
||||||
I use the Meta Pixel to see how visitors from Instagram and Facebook use this site. It sets cookies and sends data to Meta.
|
Jag använder Meta Pixel för att se hur besökare från Instagram och Facebook använder sajten. Den sätter cookies och skickar data till Meta.
|
||||||
<a href="/privacy" class="text-white underline hover:text-gray-300">Read more</a>
|
<a href="/privacy" class="text-white underline hover:text-gray-300">Läs mer</a>
|
||||||
</p>
|
</p>
|
||||||
<div class="flex gap-3 justify-end">
|
<div class="flex gap-3 justify-end">
|
||||||
<button type="button" data-consent="denied" class="px-4 py-2 rounded-full border border-white/20 text-gray-300 hover:text-white hover:border-white/40 transition-colors">Decline</button>
|
<button type="button" data-consent="denied" class="px-4 py-2 rounded-full border border-white/20 text-gray-300 hover:text-white hover:border-white/40 transition-colors">Avböj</button>
|
||||||
<button type="button" data-consent="granted" class="px-4 py-2 rounded-full bg-white text-neutral-900 font-medium hover:bg-gray-200 transition-colors">Accept</button>
|
<button type="button" data-consent="granted" class="px-4 py-2 rounded-full bg-white text-neutral-900 font-medium hover:bg-gray-200 transition-colors">Godkänn</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -78,7 +149,7 @@ const { title } = Astro.props;
|
|||||||
/* End Meta Pixel Code */
|
/* End Meta Pixel Code */
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aterkallat samtycke: ta bort Metas forsta-parts-cookies.
|
// Återkallat samtycke: ta bort Metas förstapartscookies.
|
||||||
function clearMetaCookies() {
|
function clearMetaCookies() {
|
||||||
['_fbp', '_fbc'].forEach(function (name) {
|
['_fbp', '_fbc'].forEach(function (name) {
|
||||||
document.cookie = name + '=; Max-Age=0; path=/';
|
document.cookie = name + '=; Max-Age=0; path=/';
|
||||||
@@ -108,4 +179,4 @@ const { title } = Astro.props;
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// src/lib/url.ts
|
||||||
|
// Gör om en byggsökväg till kanonisk form: "/", "/about", "/tjanster".
|
||||||
|
// Klarar både build.format "file" (/about.html) och "directory" (/about/).
|
||||||
|
export function normalizePath(p: string): string {
|
||||||
|
let out = p.replace(/\/index\.html$/, '/').replace(/\.html$/, '');
|
||||||
|
if (out.length > 1) out = out.replace(/\/+$/, '');
|
||||||
|
return out || '/';
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user