server be & fe

This commit is contained in:
azykov@mail.ru
2026-09-29 13:50:01 +03:00
commit 657009bd6c
56 changed files with 6162 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@cardy/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc -p tsconfig.json",
"test": "vitest run"
},
"dependencies": {
"zod": "^4.6.5"
},
"devDependencies": {
"typescript": "^7.0.2",
"vitest": "^5.0.2"
}
}
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { CARD_PALETTE, cardAbbreviation, randomCardColor, textColorOn } from './cardDisplay.js';
describe('card display helpers', () => {
it('abbreviates names', () => {
expect(cardAbbreviation('Magnit')).toBe('MA');
expect(cardAbbreviation('Lenta Plus Card')).toBe('LP');
expect(cardAbbreviation(' ')).toBe('?');
expect(cardAbbreviation('пятёрочка')).toBe('ПЯ');
});
it('picks palette colors and readable text colors', () => {
expect(CARD_PALETTE).toContain(randomCardColor());
expect(textColorOn('#FFFFFF')).toBe('#000000');
expect(textColorOn('#123456')).toBe('#FFFFFF');
for (const c of CARD_PALETTE) expect(textColorOn(c)).toBe('#FFFFFF');
});
});
+29
View File
@@ -0,0 +1,29 @@
/** Colors that keep white text readable. */
export const CARD_PALETTE = [
'#C62828', '#AD1457', '#6A1B9A', '#4527A0', '#283593', '#1565C0', '#01579B', '#00695C',
'#2E7D32', '#33691E', '#BF360C', '#880E4F', '#4E342E', '#37474F', '#006064', '#5D4037',
] as const;
export function randomCardColor(): string {
return CARD_PALETTE[Math.floor(Math.random() * CARD_PALETTE.length)]!;
}
/** Up to two letters: initials of the first two words, or the first two characters of a single word. */
export function cardAbbreviation(name: string): string {
const words = name.trim().split(/[\s\-_.]+/u).filter(Boolean);
if (words.length === 0) return '?';
const letters = words.length === 1 ? [...words[0]!].slice(0, 2) : words.slice(0, 2).map((w) => [...w][0]!);
return letters.join('').toUpperCase();
}
/** Black or white, whichever reads better on the given #RRGGBB background. */
export function textColorOn(background: string): '#000000' | '#FFFFFF' {
const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(background);
if (!m) return '#FFFFFF';
const [r, g, b] = [m[1], m[2], m[3]].map((h) => {
const c = parseInt(h!, 16) / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}) as [number, number, number];
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
return luminance > 0.179 ? '#000000' : '#FFFFFF';
}
+49
View File
@@ -0,0 +1,49 @@
export const CODE_TYPES = [
'EAN13',
'EAN8',
'UPCA',
'UPCE',
'CODE128',
'CODE39',
'ITF',
'CODABAR',
'QR',
'DATAMATRIX',
'PDF417',
'AZTEC',
] as const;
export type CodeType = (typeof CODE_TYPES)[number];
export const CODE_TYPE_LABELS: Record<CodeType, string> = {
EAN13: 'EAN-13',
EAN8: 'EAN-8',
UPCA: 'UPC-A',
UPCE: 'UPC-E',
CODE128: 'Code 128',
CODE39: 'Code 39',
ITF: 'ITF (Interleaved 2 of 5)',
CODABAR: 'Codabar',
QR: 'QR Code',
DATAMATRIX: 'Data Matrix',
PDF417: 'PDF417',
AZTEC: 'Aztec',
};
/** bwip-js symbology ids ("bcid") for each code type. */
export const BWIP_BCID: Record<CodeType, string> = {
EAN13: 'ean13',
EAN8: 'ean8',
UPCA: 'upca',
UPCE: 'upce',
CODE128: 'code128',
CODE39: 'code39',
ITF: 'interleaved2of5',
CODABAR: 'rationalizedCodabar',
QR: 'qrcode',
DATAMATRIX: 'datamatrix',
PDF417: 'pdf417',
AZTEC: 'azteccode',
};
export const TWO_DIMENSIONAL_CODE_TYPES: ReadonlySet<CodeType> = new Set(['QR', 'DATAMATRIX', 'AZTEC']);
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { buildLoginDeepLink, parseLoginDeepLink } from './deepLink.js';
describe('login deep link', () => {
it('round-trips server and key', () => {
const link = buildLoginDeepLink('https://cards.example.com/sub?x=1&y=2', 'AbC123');
expect(link).toBe('cardy://login?server=https%3A%2F%2Fcards.example.com%2Fsub%3Fx%3D1%26y%3D2&key=AbC123');
expect(parseLoginDeepLink(link)).toEqual({ server: 'https://cards.example.com/sub?x=1&y=2', key: 'AbC123' });
});
it('rejects foreign or incomplete links', () => {
expect(parseLoginDeepLink('https://login?server=a&key=b')).toBeNull();
expect(parseLoginDeepLink('cardy://other?server=a&key=b')).toBeNull();
expect(parseLoginDeepLink('cardy://login?server=a')).toBeNull();
expect(parseLoginDeepLink('not a url')).toBeNull();
});
});
+20
View File
@@ -0,0 +1,20 @@
export const DEEP_LINK_SCHEME = 'cardy';
export function buildLoginDeepLink(server: string, key: string): string {
return `${DEEP_LINK_SCHEME}://login?server=${encodeURIComponent(server)}&key=${encodeURIComponent(key)}`;
}
export function parseLoginDeepLink(link: string): { server: string; key: string } | null {
let url: URL;
try {
url = new URL(link.trim());
} catch {
return null;
}
// Non-special schemes put "login" into host for "cardy://login?…".
if (url.protocol !== `${DEEP_LINK_SCHEME}:` || url.host !== 'login') return null;
const server = url.searchParams.get('server');
const key = url.searchParams.get('key');
if (!server || !key) return null;
return { server, key };
}
+5
View File
@@ -0,0 +1,5 @@
export * from './codeTypes.js';
export * from './models.js';
export * from './schemas.js';
export * from './deepLink.js';
export * from './cardDisplay.js';
+61
View File
@@ -0,0 +1,61 @@
import type { CodeType } from './codeTypes.js';
export const ROLES = ['user', 'admin', 'superadmin'] as const;
export type Role = (typeof ROLES)[number];
export interface Group {
id: string;
name: string;
}
export interface ImageRef {
id: string;
file: string;
mime: string;
}
export interface Card {
id: string;
name: string;
color: string;
code: string;
codeType: CodeType;
logo: ImageRef | null;
images: ImageRef[];
imagesVersion: number;
deleted: boolean;
createdAt: string;
updatedAt: string;
}
/** Access key as shown in the admin UI (decrypted). */
export interface AccessKeyView {
id: string;
label?: string;
createdAt: string;
key: string;
deepLink: string;
}
export interface UserView {
email: string;
groupId: string;
role: Role;
keys: AccessKeyView[];
}
export interface MeResponse {
email: string;
role: Role;
group: Group;
currentGroup: Group;
}
export interface ApiAuthResponse {
email: string;
group: Group;
}
export interface ErrorResponse {
error: string;
}
+43
View File
@@ -0,0 +1,43 @@
import { z } from 'zod';
import { CODE_TYPES } from './codeTypes.js';
import { ROLES } from './models.js';
export const colorSchema = z.string().regex(/^#[0-9a-fA-F]{6}$/, 'Color must be #RRGGBB');
export const cardFieldsSchema = z.object({
name: z.string().trim().min(1, 'Name is required').max(200),
color: colorSchema,
code: z.string().min(1, 'Code is required').max(4000),
codeType: z.enum(CODE_TYPES),
});
export type CardFields = z.infer<typeof cardFieldsSchema>;
export const cardPatchSchema = cardFieldsSchema.partial().extend({
deleted: z.boolean().optional(),
});
export type CardPatch = z.infer<typeof cardPatchSchema>;
export const emailSchema = z.string().trim().toLowerCase().pipe(z.email());
export const groupInputSchema = z.object({
name: z.string().trim().min(1, 'Name is required').max(100),
});
export const userCreateSchema = z.object({
email: emailSchema,
role: z.enum(ROLES).default('user'),
groupId: z.string().optional(),
});
export const userPatchSchema = z.object({
role: z.enum(ROLES).optional(),
groupId: z.string().optional(),
});
export const keyCreateSchema = z.object({
label: z.string().trim().max(100).optional(),
});
export const currentGroupSchema = z.object({
groupId: z.string().min(1),
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022", "DOM"]
},
"include": ["src"]
}