server be & fe
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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']);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './codeTypes.js';
|
||||
export * from './models.js';
|
||||
export * from './schemas.js';
|
||||
export * from './deepLink.js';
|
||||
export * from './cardDisplay.js';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022", "DOM"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user