Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84772ffdd6 | ||
|
|
bdd9fc4602 | ||
|
|
d8b20fd513 |
No files matched your search
@@ -6,4 +6,4 @@ resources:
|
|||||||
- service.yaml
|
- service.yaml
|
||||||
images:
|
images:
|
||||||
- name: ghcr.io/deytenit/ermnvldmr.com/docs
|
- name: ghcr.io/deytenit/ermnvldmr.com/docs
|
||||||
newTag: sha-0e77ea1
|
newTag: sha-9200529
|
||||||
@@ -6,4 +6,4 @@ metadata:
|
|||||||
data:
|
data:
|
||||||
DATABASE_TYPE: "postgresql"
|
DATABASE_TYPE: "postgresql"
|
||||||
CLIENT_IP_HEADER: "X-Forwarded-For"
|
CLIENT_IP_HEADER: "X-Forwarded-For"
|
||||||
TRACKER_SCRIPT_NAME: "beacon.js"
|
TRACKER_SCRIPT_NAME: "trace.js"
|
||||||
@@ -18,7 +18,7 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: umami
|
app: umami
|
||||||
annotations:
|
annotations:
|
||||||
config/tracker-script-name: "beacon.js"
|
config/tracker-script-name: "trace.js"
|
||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: umami
|
- name: umami
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ resources:
|
|||||||
- service.yaml
|
- service.yaml
|
||||||
images:
|
images:
|
||||||
- name: ghcr.io/deytenit/ermnvldmr.com/www
|
- name: ghcr.io/deytenit/ermnvldmr.com/www
|
||||||
newTag: sha-24f3311
|
newTag: sha-9200529
|
||||||
@@ -20,7 +20,7 @@ spec:
|
|||||||
- host: www.ermnvldmr.com
|
- host: www.ermnvldmr.com
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- path: /beacon
|
- path: /trace
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
@@ -30,7 +30,7 @@ spec:
|
|||||||
- host: docs.ermnvldmr.com
|
- host: docs.ermnvldmr.com
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- path: /beacon
|
- path: /trace
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ metadata:
|
|||||||
spec:
|
spec:
|
||||||
stripPrefix:
|
stripPrefix:
|
||||||
prefixes:
|
prefixes:
|
||||||
- /beacon
|
- /trace
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { umamiAnalytics } from './umami';
|
||||||
|
|
||||||
|
describe('umamiAnalytics', () => {
|
||||||
|
it('suppresses script injection in non-production environments when not forced', () => {
|
||||||
|
const provider = umamiAnalytics({
|
||||||
|
serverUrl: '/pulse',
|
||||||
|
websiteId: 'test-website-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(provider.getHtmlTags(false)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('injects script tag in non-production when force is true', () => {
|
||||||
|
const provider = umamiAnalytics({
|
||||||
|
serverUrl: '/pulse',
|
||||||
|
websiteId: 'test-website-id',
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const tags = provider.getHtmlTags(false);
|
||||||
|
expect(tags).toHaveLength(1);
|
||||||
|
expect(tags[0]?.attrs?.src).toBe('/pulse/script.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates expected script attributes in production builds', () => {
|
||||||
|
const provider = umamiAnalytics({
|
||||||
|
serverUrl: '/trace/',
|
||||||
|
scriptName: 'trace.js',
|
||||||
|
hostUrl: '/trace',
|
||||||
|
websiteId: '41c7699a-0675-4f49-bcd0-3df47a66b6b1',
|
||||||
|
domains: 'ermnvldmr.com,www.ermnvldmr.com',
|
||||||
|
doNotTrack: true,
|
||||||
|
tag: 'landing',
|
||||||
|
});
|
||||||
|
|
||||||
|
const tags = provider.getHtmlTags(true);
|
||||||
|
expect(tags).toEqual([
|
||||||
|
{
|
||||||
|
tag: 'script',
|
||||||
|
head: true,
|
||||||
|
publicPath: false,
|
||||||
|
attrs: {
|
||||||
|
defer: true,
|
||||||
|
src: '/trace/trace.js',
|
||||||
|
'data-website-id': '41c7699a-0675-4f49-bcd0-3df47a66b6b1',
|
||||||
|
'data-host-url': '/trace',
|
||||||
|
'data-domains': 'ermnvldmr.com,www.ermnvldmr.com',
|
||||||
|
'data-do-not-track': 'true',
|
||||||
|
'data-tag': 'landing',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { Switch } from './Switch';
|
import { Switch } from './Switch';
|
||||||
|
import { VStack } from '../VStack/VStack';
|
||||||
|
|
||||||
import type { Meta, StoryObj } from '@storybook/react';
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
@@ -8,6 +9,16 @@ const meta: Meta<typeof Switch> = {
|
|||||||
title: 'Components/Switch',
|
title: 'Components/Switch',
|
||||||
component: Switch,
|
component: Switch,
|
||||||
tags: ['autodocs'],
|
tags: ['autodocs'],
|
||||||
|
argTypes: {
|
||||||
|
size: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['s', 'm', 'l'],
|
||||||
|
},
|
||||||
|
variant: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['solid', 'outline', 'ghost'],
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default meta;
|
export default meta;
|
||||||
@@ -19,6 +30,16 @@ export const Default: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Sizes: Story = {
|
||||||
|
render: () => (
|
||||||
|
<VStack gap={4}>
|
||||||
|
<Switch size="s">Small Switch</Switch>
|
||||||
|
<Switch size="m">Medium Switch</Switch>
|
||||||
|
<Switch size="l">Large Switch</Switch>
|
||||||
|
</VStack>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
export const Selected: Story = {
|
export const Selected: Story = {
|
||||||
args: {
|
args: {
|
||||||
children: 'Selected State',
|
children: 'Selected State',
|
||||||
|
|||||||
@@ -66,5 +66,18 @@ describe('components/Switch', () => {
|
|||||||
expect(track).toHaveClass('data-[selected=true]:bg-primary');
|
expect(track).toHaveClass('data-[selected=true]:bg-primary');
|
||||||
expect(thumb).toHaveClass('data-[selected=true]:bg-primary-text');
|
expect(thumb).toHaveClass('data-[selected=true]:bg-primary-text');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('applies size variant classes to track and thumb', () => {
|
||||||
|
const { container, rerender } = render(<Switch size="s">Small</Switch>);
|
||||||
|
const track = container.querySelector('[aria-hidden="true"]');
|
||||||
|
const thumb = track?.firstElementChild;
|
||||||
|
|
||||||
|
expect(track).toHaveClass('w-7', 'h-4');
|
||||||
|
expect(thumb).toHaveClass('w-2.5', 'h-2.5');
|
||||||
|
|
||||||
|
rerender(<Switch size="l">Large</Switch>);
|
||||||
|
expect(track).toHaveClass('w-14', 'h-8');
|
||||||
|
expect(thumb).toHaveClass('w-6', 'h-6');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -14,6 +14,11 @@ import type { AriaSwitchProps } from 'react-aria';
|
|||||||
*/
|
*/
|
||||||
export type SwitchVariant = 'solid' | 'outline' | 'ghost';
|
export type SwitchVariant = 'solid' | 'outline' | 'ghost';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch size variants.
|
||||||
|
*/
|
||||||
|
export type SwitchSize = 's' | 'm' | 'l';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Switch color themes.
|
* Switch color themes.
|
||||||
*/
|
*/
|
||||||
@@ -39,12 +44,14 @@ export interface SwitchProps extends AriaSwitchProps, ClassNameProps, DataAttrib
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
/** Visual style variant */
|
/** Visual style variant */
|
||||||
variant?: SwitchVariant;
|
variant?: SwitchVariant;
|
||||||
|
/** Switch size */
|
||||||
|
size?: SwitchSize;
|
||||||
/** Color theme */
|
/** Color theme */
|
||||||
color?: SwitchColor;
|
color?: SwitchColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trackVariants = cva(
|
const trackVariants = cva(
|
||||||
'relative w-11 h-6 transition-all duration-200 ease-in-out rounded-full outline-none flex items-center',
|
'relative transition-all duration-200 ease-in-out rounded-full outline-none flex items-center',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
@@ -52,6 +59,11 @@ const trackVariants = cva(
|
|||||||
outline: 'border-2 data-[selected=false]:bg-transparent data-[selected=false]:border-muted',
|
outline: 'border-2 data-[selected=false]:bg-transparent data-[selected=false]:border-muted',
|
||||||
ghost: 'data-[selected=false]:bg-transparent data-[selected=false]:border-transparent',
|
ghost: 'data-[selected=false]:bg-transparent data-[selected=false]:border-transparent',
|
||||||
},
|
},
|
||||||
|
size: {
|
||||||
|
s: 'w-7 h-4',
|
||||||
|
m: 'w-11 h-6',
|
||||||
|
l: 'w-14 h-8',
|
||||||
|
},
|
||||||
color: {
|
color: {
|
||||||
primary: '',
|
primary: '',
|
||||||
'primary-negative': '',
|
'primary-negative': '',
|
||||||
@@ -118,19 +130,25 @@ const trackVariants = cva(
|
|||||||
],
|
],
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'solid',
|
variant: 'solid',
|
||||||
|
size: 'm',
|
||||||
color: 'primary',
|
color: 'primary',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const thumbVariants = cva(
|
const thumbVariants = cva(
|
||||||
'w-4 h-4 transition-all duration-200 ease-in-out transform rounded-full shadow-sm data-[selected=false]:bg-[var(--rb-text)] data-[selected=false]:translate-x-0',
|
'transition-all duration-200 ease-in-out transform rounded-full shadow-sm data-[selected=false]:bg-[var(--rb-text)] data-[selected=false]:translate-x-0',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
solid: 'ml-1 data-[selected=true]:translate-x-5',
|
solid: '',
|
||||||
outline: 'ml-0.5 data-[selected=true]:translate-x-4.5',
|
outline: '',
|
||||||
ghost: 'ml-1 data-[selected=true]:translate-x-5',
|
ghost: '',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
s: 'w-2.5 h-2.5',
|
||||||
|
m: 'w-4 h-4',
|
||||||
|
l: 'w-6 h-6',
|
||||||
},
|
},
|
||||||
color: {
|
color: {
|
||||||
primary: '',
|
primary: '',
|
||||||
@@ -146,6 +164,17 @@ const thumbVariants = cva(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
compoundVariants: [
|
compoundVariants: [
|
||||||
|
// size + variant offsets
|
||||||
|
{ size: 's', variant: 'solid', className: 'ml-0.5 data-[selected=true]:translate-x-3.5' },
|
||||||
|
{ size: 's', variant: 'outline', className: 'ml-0.5 data-[selected=true]:translate-x-2.5' },
|
||||||
|
{ size: 's', variant: 'ghost', className: 'ml-0.5 data-[selected=true]:translate-x-3.5' },
|
||||||
|
{ size: 'm', variant: 'solid', className: 'ml-1 data-[selected=true]:translate-x-5' },
|
||||||
|
{ size: 'm', variant: 'outline', className: 'ml-0.5 data-[selected=true]:translate-x-4.5' },
|
||||||
|
{ size: 'm', variant: 'ghost', className: 'ml-1 data-[selected=true]:translate-x-5' },
|
||||||
|
{ size: 'l', variant: 'solid', className: 'ml-1 data-[selected=true]:translate-x-6' },
|
||||||
|
{ size: 'l', variant: 'outline', className: 'ml-0.5 data-[selected=true]:translate-x-5.5' },
|
||||||
|
{ size: 'l', variant: 'ghost', className: 'ml-1 data-[selected=true]:translate-x-6' },
|
||||||
|
|
||||||
// primary
|
// primary
|
||||||
{ variant: 'solid', color: 'primary', className: 'data-[selected=true]:bg-primary-text' },
|
{ variant: 'solid', color: 'primary', className: 'data-[selected=true]:bg-primary-text' },
|
||||||
{ variant: 'outline', color: 'primary', className: 'data-[selected=true]:bg-primary-text' },
|
{ variant: 'outline', color: 'primary', className: 'data-[selected=true]:bg-primary-text' },
|
||||||
@@ -198,6 +227,7 @@ const thumbVariants = cva(
|
|||||||
],
|
],
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
variant: 'solid',
|
variant: 'solid',
|
||||||
|
size: 'm',
|
||||||
color: 'primary',
|
color: 'primary',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -216,7 +246,7 @@ const thumbVariants = cva(
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function Switch(props: SwitchProps): React.JSX.Element {
|
export function Switch(props: SwitchProps): React.JSX.Element {
|
||||||
const { children, className, variant = 'solid', color = 'primary' } = props;
|
const { children, className, variant = 'solid', size = 'm', color = 'primary' } = props;
|
||||||
const state = useToggleState(props);
|
const state = useToggleState(props);
|
||||||
const ref = useRef<HTMLInputElement>(null);
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
const { inputProps } = useSwitch(props, state, ref);
|
const { inputProps } = useSwitch(props, state, ref);
|
||||||
@@ -238,14 +268,14 @@ export function Switch(props: SwitchProps): React.JSX.Element {
|
|||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
trackVariants({ variant, color }),
|
trackVariants({ variant, size, color }),
|
||||||
isFocusVisible && 'ring-2 ring-offset-2 ring-[var(--rb-ring)]',
|
isFocusVisible && 'ring-2 ring-offset-2 ring-[var(--rb-ring)]',
|
||||||
props.isDisabled && 'grayscale-[0.5]'
|
props.isDisabled && 'grayscale-[0.5]'
|
||||||
)}
|
)}
|
||||||
data-selected={state.isSelected}
|
data-selected={state.isSelected}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={thumbVariants({ variant, color })}
|
className={thumbVariants({ variant, size, color })}
|
||||||
data-selected={state.isSelected}
|
data-selected={state.isSelected}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -253,7 +283,7 @@ export function Switch(props: SwitchProps): React.JSX.Element {
|
|||||||
<Text
|
<Text
|
||||||
bold={state.isSelected}
|
bold={state.isSelected}
|
||||||
color={state.isSelected ? 'default' : 'muted'}
|
color={state.isSelected ? 'default' : 'muted'}
|
||||||
size="s"
|
size={size}
|
||||||
type="label"
|
type="label"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default defineCli({
|
|||||||
name: 'sync:static',
|
name: 'sync:static',
|
||||||
description: 'Sync static assets to S3',
|
description: 'Sync static assets to S3',
|
||||||
targetPrefix: 'docs',
|
targetPrefix: 'docs',
|
||||||
sourceDir: './static',
|
sourceDir: './public/static',
|
||||||
sourceFilesPattern: 'content/**/*.{md,html}',
|
sourceFilesPattern: 'content/**/*.{md,html}',
|
||||||
urlPrefixes: ['/static/', '/images/'],
|
urlPrefixes: ['/static/', '/images/'],
|
||||||
bucket: env('COM_ERMNVLDMR_STATIC_S3_BUCKET'),
|
bucket: env('COM_ERMNVLDMR_STATIC_S3_BUCKET'),
|
||||||
@@ -16,7 +16,7 @@ export default defineCli({
|
|||||||
accessKeyId: env('COM_ERMNVLDMR_STATIC_AWS_ACCESS_KEY_ID'),
|
accessKeyId: env('COM_ERMNVLDMR_STATIC_AWS_ACCESS_KEY_ID'),
|
||||||
secretAccessKey: env('COM_ERMNVLDMR_STATIC_AWS_SECRET_ACCESS_KEY'),
|
secretAccessKey: env('COM_ERMNVLDMR_STATIC_AWS_SECRET_ACCESS_KEY'),
|
||||||
hooks: {
|
hooks: {
|
||||||
beforeSync: 'bash ../../ci/scripts/optimize-images.sh ./static',
|
beforeSync: 'bash ../../ci/scripts/optimize-images.sh ./public/static',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
(function () {
|
||||||
|
var KEY = "umami.disabled";
|
||||||
|
var toggles = document.querySelectorAll("[data-umami-toggle]");
|
||||||
|
if (!toggles.length) return;
|
||||||
|
|
||||||
|
function sync() {
|
||||||
|
var enabled = true;
|
||||||
|
try {
|
||||||
|
enabled = window.localStorage.getItem(KEY) !== "1";
|
||||||
|
} catch (e) {}
|
||||||
|
toggles.forEach(function (el) {
|
||||||
|
el.checked = enabled;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
sync();
|
||||||
|
|
||||||
|
toggles.forEach(function (el) {
|
||||||
|
el.addEventListener("change", function () {
|
||||||
|
try {
|
||||||
|
if (el.checked) {
|
||||||
|
window.localStorage.removeItem(KEY);
|
||||||
|
} else {
|
||||||
|
window.localStorage.setItem(KEY, "1");
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("storage", function (e) {
|
||||||
|
if (e.key === KEY || e.key === null) {
|
||||||
|
sync();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -48,10 +48,10 @@ params:
|
|||||||
# Analytics configuration
|
# Analytics configuration
|
||||||
analytics:
|
analytics:
|
||||||
umami:
|
umami:
|
||||||
serverURL: '/beacon'
|
serverURL: '/trace'
|
||||||
scriptName: 'beacon.js'
|
scriptName: 'trace.js'
|
||||||
hostURL: '/beacon'
|
hostURL: '/trace'
|
||||||
websiteID: 'fbdadf3f-3484-4186-a2c2-cedcf76cf44f'
|
websiteID: '6e65136b-48c2-4519-816f-023932ceb775'
|
||||||
domains: 'docs.ermnvldmr.com'
|
domains: 'docs.ermnvldmr.com'
|
||||||
doNotTrack: 'true'
|
doNotTrack: 'true'
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
copyright: '© 2025 Eremin Vladimir. CC-BY-4.0. All rights reserved.'
|
copyright: '© 2026 Vladimir Eremin · <a class="hx:underline hx:decoration-from-font hx:underline-offset-2 hx:hover:text-gray-900 hx:dark:hover:text-gray-100" href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener noreferrer">CC-BY 4.0</a><br/><span class="hx:inline-flex hx:items-center hx:flex-wrap hx:gap-1.5 hx:mt-1 hx:text-[11px] hx:text-gray-400 hx:dark:text-neutral-500">To make this site better, I collect anonymous analytics <label class="hx:inline-flex hx:items-center hx:cursor-pointer hx:align-middle"><input type="checkbox" data-umami-toggle aria-label="Toggle anonymous telemetry" checked class="hx:cursor-pointer hx:h-3.5 hx:w-3.5" /></label> · <a class="hx:underline hx:decoration-from-font hx:underline-offset-2 hx:hover:text-gray-600 hx:dark:hover:text-neutral-400" href="https://static.ermnvldmr.com/docs/privacy-policy.txt" target="_blank" rel="noopener noreferrer">Privacy Policy</a></span>'
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
copyright: '© 2025 Ерёмин Владимир. CC-BY-4.0. Все права защищены.'
|
copyright: '© 2026 Владимир Ерёмин · <a class="hx:underline hx:decoration-from-font hx:underline-offset-2 hx:hover:text-gray-900 hx:dark:hover:text-gray-100" href="https://creativecommons.org/licenses/by/4.0/" target="_blank" rel="noopener noreferrer">CC-BY 4.0</a><br/><span class="hx:inline-flex hx:items-center hx:flex-wrap hx:gap-1.5 hx:mt-1 hx:text-[11px] hx:text-gray-400 hx:dark:text-neutral-500">Из желания сделать этот сайт лучше — я собираю анонимную аналитику <label class="hx:inline-flex hx:items-center hx:cursor-pointer hx:align-middle"><input type="checkbox" data-umami-toggle aria-label="Переключить анонимную телеметрию" checked class="hx:cursor-pointer hx:h-3.5 hx:w-3.5" /></label> · <a class="hx:underline hx:decoration-from-font hx:underline-offset-2 hx:hover:text-gray-600 hx:dark:hover:text-neutral-400" href="https://static.ermnvldmr.com/docs/privacy-policy.txt" target="_blank" rel="noopener noreferrer">Политика конфиденциальности</a></span>'
|
||||||
note: Кстати
|
note: Кстати
|
||||||
tip: Полезно
|
tip: Полезно
|
||||||
important: Важно
|
important: Важно
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.8 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 311 KiB |
@@ -2,13 +2,14 @@ import { localDevServer } from '@ermnvldmr/rsbuild-config/dev';
|
|||||||
import { defineSSGServiceConfig, discoverEntries, umamiAnalytics } from '@ermnvldmr/ssg/dev';
|
import { defineSSGServiceConfig, discoverEntries, umamiAnalytics } from '@ermnvldmr/ssg/dev';
|
||||||
import { THEME_INIT_SCRIPT, THEME_INIT_STYLES } from '@ermnvldmr/ui/dev';
|
import { THEME_INIT_SCRIPT, THEME_INIT_STYLES } from '@ermnvldmr/ui/dev';
|
||||||
|
|
||||||
const UMAMI_STATIC_WEBSITE_ID = '0831532a-2cec-4955-8f5b-452d8bba96d8';
|
const UMAMI_STATIC_WEBSITE_ID = 'de274e99-9e1e-41bb-981a-9a4fd21d39c5';
|
||||||
|
|
||||||
export default defineSSGServiceConfig({
|
export default defineSSGServiceConfig({
|
||||||
server: localDevServer({ port: 3001 }),
|
server: localDevServer({ port: 3001 }),
|
||||||
analytics: [
|
analytics: [
|
||||||
umamiAnalytics({
|
umamiAnalytics({
|
||||||
serverUrl: 'https://umami.ermnvldmr.com',
|
serverUrl: 'https://umami.ermnvldmr.com',
|
||||||
|
scriptName: 'trace.js',
|
||||||
websiteId: UMAMI_STATIC_WEBSITE_ID,
|
websiteId: UMAMI_STATIC_WEBSITE_ID,
|
||||||
domains: 'static.ermnvldmr.com',
|
domains: 'static.ermnvldmr.com',
|
||||||
doNotTrack: true,
|
doNotTrack: true,
|
||||||
|
|||||||
@@ -33,18 +33,18 @@ const MIME: Record<string, string> = {
|
|||||||
'.txt': 'text/plain',
|
'.txt': 'text/plain',
|
||||||
};
|
};
|
||||||
|
|
||||||
const UMAMI_WWW_WEBSITE_ID = '4fca5ac2-4fe4-4626-b708-933c37bfe918';
|
const UMAMI_WWW_WEBSITE_ID = '41c7699a-0675-4f49-bcd0-3df47a66b6b1';
|
||||||
|
|
||||||
export default defineSSGServiceConfig(
|
export default defineSSGServiceConfig(
|
||||||
mergeConfig(localeRsbuildConfig(), {
|
mergeConfig(localeRsbuildConfig(), {
|
||||||
server: localDevServer({ port: 3000 }),
|
server: localDevServer({ port: 3000 }),
|
||||||
analytics: [
|
analytics: [
|
||||||
umamiAnalytics({
|
umamiAnalytics({
|
||||||
serverUrl: '/beacon',
|
serverUrl: '/trace',
|
||||||
scriptName: 'beacon.js',
|
scriptName: 'trace.js',
|
||||||
hostUrl: '/beacon',
|
hostUrl: '/trace',
|
||||||
websiteId: UMAMI_WWW_WEBSITE_ID,
|
websiteId: UMAMI_WWW_WEBSITE_ID,
|
||||||
domains: 'ermnvldmr.com,www.ermnvldmr.com',
|
domains: 'www.ermnvldmr.com',
|
||||||
doNotTrack: true,
|
doNotTrack: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { createKeyset } from '@ermnvldmr/i18n';
|
import { createKeyset } from '@ermnvldmr/i18n';
|
||||||
|
|
||||||
export const { t } = createKeyset({
|
export const { t, tRaw } = createKeyset({
|
||||||
'Vladimir Eremin': { en: 'Vladimir Eremin', ru: 'Владимир Ерёмин' },
|
'Vladimir Eremin': { en: 'Vladimir Eremin', ru: 'Владимир Ерёмин' },
|
||||||
|
'Privacy Policy': { en: 'Privacy Policy', ru: 'Политика конфиденциальности' },
|
||||||
|
'Toggle anonymous telemetry': {
|
||||||
|
en: 'Toggle anonymous telemetry',
|
||||||
|
ru: 'Переключить анонимную телеметрию',
|
||||||
|
},
|
||||||
|
'To make this site better, I collect anonymous analytics · {privacyLink}': {
|
||||||
|
en: 'To\u00A0make this site better, I\u00A0collect anonymous analytics · {privacyLink}',
|
||||||
|
ru: 'Из\u00A0желания сделать этот сайт лучше\u00A0— я\u00A0собираю анонимную аналитику · {privacyLink}',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import { ThemeSwitch } from '@ermnvldmr/kits';
|
import { ThemeSwitch } from '@ermnvldmr/kits';
|
||||||
import { Container, HStack, Icon, Link, Text, VStack } from '@ermnvldmr/ui';
|
import { createLocalStorage, useLocalStorage } from '@ermnvldmr/stl';
|
||||||
|
import { Container, HStack, Icon, Link, Switch, Text, VStack } from '@ermnvldmr/ui';
|
||||||
import { Monitor, Moon, Sun } from 'lucide-react';
|
import { Monitor, Moon, Sun } from 'lucide-react';
|
||||||
import React from 'react';
|
import React, { useCallback } from 'react';
|
||||||
|
|
||||||
import { LocaleSwitch } from '../LocaleSwitch/LocaleSwitch';
|
import { LocaleSwitch } from '../LocaleSwitch/LocaleSwitch';
|
||||||
import { RainbowSeparator } from '../RainbowSeparator';
|
import { RainbowSeparator } from '../RainbowSeparator';
|
||||||
import { t } from './Footer.i18n';
|
import { t, tRaw } from './Footer.i18n';
|
||||||
|
|
||||||
|
const umamiDisabledStorage = createLocalStorage<number | null>('umami.disabled', null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for the Footer component.
|
* Props for the Footer component.
|
||||||
@@ -17,7 +20,7 @@ export interface FooterProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The standard footer for the www service.
|
* The standard footer for the www service.
|
||||||
* Displays copyright information, license, and theme switcher.
|
* Displays copyright information, license, privacy policy, and theme switcher.
|
||||||
*
|
*
|
||||||
* @returns A Footer component.
|
* @returns A Footer component.
|
||||||
* @example
|
* @example
|
||||||
@@ -27,33 +30,65 @@ export interface FooterProps {
|
|||||||
*/
|
*/
|
||||||
export const Footer = ({ currentPath }: FooterProps) => {
|
export const Footer = ({ currentPath }: FooterProps) => {
|
||||||
const currentYear = new Date().getFullYear();
|
const currentYear = new Date().getFullYear();
|
||||||
|
const [umamiDisabled, setUmamiDisabled] = useLocalStorage(umamiDisabledStorage);
|
||||||
|
const isTelemetryEnabled = umamiDisabled !== 1;
|
||||||
|
|
||||||
|
const handleTelemetryToggle = useCallback(
|
||||||
|
(isSelected: boolean): void => {
|
||||||
|
if (isSelected) {
|
||||||
|
umamiDisabledStorage.remove();
|
||||||
|
} else {
|
||||||
|
setUmamiDisabled(1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setUmamiDisabled]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack as="footer" className="w-full" gap={0}>
|
<VStack align="stretch" as="footer" gap={0}>
|
||||||
<RainbowSeparator />
|
<RainbowSeparator />
|
||||||
<Container bg="transparent" className="w-full max-sm:p-8" padding={12}>
|
<Container bg="transparent" padding={12}>
|
||||||
<HStack
|
<VStack align="stretch" gap={1}>
|
||||||
align="center"
|
<HStack align="center" gap={4} justify="between" wrap="wrap">
|
||||||
className="w-full max-sm:flex-col max-sm:items-start max-sm:gap-8"
|
|
||||||
justify="between"
|
|
||||||
>
|
|
||||||
<VStack align="start" gap={1}>
|
|
||||||
<Text color="muted" size="s">
|
<Text color="muted" size="s">
|
||||||
© {currentYear} {t('Vladimir Eremin')}
|
© {currentYear} {t('Vladimir Eremin')} ·{' '}
|
||||||
|
<Link href="https://creativecommons.org/licenses/by/4.0/" size="s">
|
||||||
|
CC-BY 4.0
|
||||||
|
</Link>
|
||||||
</Text>
|
</Text>
|
||||||
<Link href="https://creativecommons.org/licenses/by/4.0/" size="s">
|
|
||||||
CC-BY 4.0
|
|
||||||
</Link>
|
|
||||||
</VStack>
|
|
||||||
<VStack align="end" gap={4}>
|
|
||||||
<ThemeSwitch
|
<ThemeSwitch
|
||||||
darkIcon={<Icon icon={Moon} />}
|
darkIcon={<Icon icon={Moon} />}
|
||||||
lightIcon={<Icon icon={Sun} />}
|
lightIcon={<Icon icon={Sun} />}
|
||||||
systemIcon={<Icon icon={Monitor} />}
|
systemIcon={<Icon icon={Monitor} />}
|
||||||
/>
|
/>
|
||||||
|
</HStack>
|
||||||
|
<HStack align="center" gap={4} justify="between" wrap="wrap">
|
||||||
|
<HStack align="center" gap={2} wrap="wrap">
|
||||||
|
<Text color="muted" size="s" type="label">
|
||||||
|
{tRaw('To make this site better, I collect anonymous analytics · {privacyLink}', {
|
||||||
|
privacyLink: (
|
||||||
|
<Link
|
||||||
|
key="privacy-link"
|
||||||
|
href="https://static.ermnvldmr.com/www/privacy-policy.txt"
|
||||||
|
size="s"
|
||||||
|
type="label"
|
||||||
|
>
|
||||||
|
{t('Privacy Policy')}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Switch
|
||||||
|
aria-label={t('Toggle anonymous telemetry')}
|
||||||
|
isSelected={isTelemetryEnabled}
|
||||||
|
size="s"
|
||||||
|
variant="outline"
|
||||||
|
onChange={handleTelemetryToggle}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
<LocaleSwitch currentPath={currentPath} />
|
<LocaleSwitch currentPath={currentPath} />
|
||||||
</VStack>
|
</HStack>
|
||||||
</HStack>
|
</VStack>
|
||||||
</Container>
|
</Container>
|
||||||
</VStack>
|
</VStack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import '@testing-library/jest-dom';
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
import { render, screen } from '@testing-library/react';
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
@@ -12,7 +13,10 @@ describe('ArticleLayout', () => {
|
|||||||
createdDate: new Date('2026-01-01T00:00:00Z'),
|
createdDate: new Date('2026-01-01T00:00:00Z'),
|
||||||
};
|
};
|
||||||
|
|
||||||
it('renders title, created date, and content', () => {
|
it('renders title, created date, and content', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
window.localStorage.removeItem('umami.disabled');
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ArticleLayout {...defaultProps}>
|
<ArticleLayout {...defaultProps}>
|
||||||
Article content body
|
Article content body
|
||||||
@@ -21,6 +25,21 @@ describe('ArticleLayout', () => {
|
|||||||
|
|
||||||
expect(screen.getByRole('heading', { level: 1, name: 'Test Article Title' })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { level: 1, name: 'Test Article Title' })).toBeInTheDocument();
|
||||||
expect(screen.getByText('Article content body')).toBeInTheDocument();
|
expect(screen.getByText('Article content body')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: 'Privacy Policy' })).toHaveAttribute(
|
||||||
|
'href',
|
||||||
|
'https://static.ermnvldmr.com/www/privacy-policy.txt'
|
||||||
|
);
|
||||||
|
|
||||||
|
const telemetrySwitch = screen.getByRole('switch', { name: 'Toggle anonymous telemetry' });
|
||||||
|
expect(telemetrySwitch).toBeChecked();
|
||||||
|
|
||||||
|
await user.click(telemetrySwitch);
|
||||||
|
expect(telemetrySwitch).not.toBeChecked();
|
||||||
|
expect(window.localStorage.getItem('umami.disabled')).toBe('1');
|
||||||
|
|
||||||
|
await user.click(telemetrySwitch);
|
||||||
|
expect(telemetrySwitch).toBeChecked();
|
||||||
|
expect(window.localStorage.getItem('umami.disabled')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders updated date when updatedDate prop is provided', () => {
|
it('renders updated date when updatedDate prop is provided', () => {
|
||||||
|
|||||||
@@ -23,11 +23,12 @@ export const LocaleSwitch: React.FC<LocaleSwitchProps> = ({ currentPath }) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
className="text-neutral-400 hover:text-neutral-500 dark:text-neutral-500 dark:hover:text-neutral-400"
|
color="muted"
|
||||||
data-umami-event="locale-switch"
|
data-umami-event="locale-switch"
|
||||||
data-umami-event-target={OTHER_LOCALE}
|
data-umami-event-target={OTHER_LOCALE}
|
||||||
href={href}
|
href={href}
|
||||||
size="s"
|
size="s"
|
||||||
|
type="label"
|
||||||
>
|
>
|
||||||
{OTHER_LOCALE_LABEL}
|
{OTHER_LOCALE_LABEL}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
Reference in new issue
Block a user