Init
Build and Push Telemt Panel Docker Image / build-and-push (push) Failing after 38s
Build and Push Telemt Panel Docker Image / create-release (push) Skipped

This commit is contained in:
Denozordec
2026-08-04 18:33:48 +07:00
commit b5f31c1083
227 changed files with 31375 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
module github.com/denozord/telemtPanel/agent
go 1.22
+198
View File
@@ -0,0 +1,198 @@
// Telemt Panel agent (fleet mode)
// Build: go build -o telemt-panel-agent .
// Run: ./telemt-panel-agent -panel-url https://panel -token <enrollment> OR -state /var/lib/...
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"time"
)
type enrollResponse struct {
AgentID string `json:"agentId"`
AgentToken string `json:"agentToken"`
PanelURL string `json:"panelUrl"`
}
type job struct {
ID string `json:"id"`
Type string `json:"type"`
Payload map[string]any `json:"payload"`
}
type stateFile struct {
AgentID string `json:"agentId"`
AgentToken string `json:"agentToken"`
PanelURL string `json:"panelUrl"`
}
func main() {
panelURL := flag.String("panel-url", os.Getenv("PANEL_URL"), "panel base URL")
token := flag.String("token", os.Getenv("ENROLLMENT_TOKEN"), "enrollment token")
statePath := flag.String("state", "/var/lib/telemt-panel-agent/state.json", "state file")
telemtURL := flag.String("telemt-url", envOr("TELEMT_API_URL", "http://127.0.0.1:9091"), "local Telemt API")
telemtAuth := flag.String("telemt-auth", os.Getenv("TELEMT_AUTH_HEADER"), "Telemt Authorization header")
flag.Parse()
st, err := loadOrEnroll(*statePath, *panelURL, *token)
if err != nil {
log.Fatal(err)
}
client := &http.Client{Timeout: 30 * time.Second}
log.Printf("agent %s connected to %s", st.AgentID, st.PanelURL)
for {
jobs, err := fetchJobs(client, st)
if err != nil {
log.Printf("jobs poll: %v", err)
time.Sleep(5 * time.Second)
continue
}
for _, j := range jobs {
if j.Type != "telemt.proxy" {
_ = postResult(client, st, j.ID, false, nil, "unsupported job type")
continue
}
result, err := doTelemtProxy(client, *telemtURL, *telemtAuth, j.Payload)
if err != nil {
_ = postResult(client, st, j.ID, false, nil, err.Error())
continue
}
_ = postResult(client, st, j.ID, true, result, "")
}
time.Sleep(2 * time.Second)
}
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func loadOrEnroll(path, panelURL, token string) (*stateFile, error) {
if b, err := os.ReadFile(path); err == nil {
var st stateFile
if json.Unmarshal(b, &st) == nil && st.AgentToken != "" {
return &st, nil
}
}
if panelURL == "" || token == "" {
return nil, fmt.Errorf("need -panel-url and -token for first enroll (or existing state file)")
}
hostname, _ := os.Hostname()
body, _ := json.Marshal(map[string]string{
"token": token,
"hostname": hostname,
"name": hostname,
"agentVersion": "0.1.0",
})
res, err := http.Post(panelURL+"/api/agent/enroll", "application/json", bytes.NewReader(body))
if err != nil {
return nil, err
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if res.StatusCode >= 300 {
return nil, fmt.Errorf("enroll %s: %s", res.Status, string(raw))
}
var er enrollResponse
if err := json.Unmarshal(raw, &er); err != nil {
return nil, err
}
st := &stateFile{AgentID: er.AgentID, AgentToken: er.AgentToken, PanelURL: er.PanelURL}
_ = os.MkdirAll(filepath.Dir(path), 0o755)
b, _ := json.MarshalIndent(st, "", " ")
_ = os.WriteFile(path, b, 0o600)
return st, nil
}
func fetchJobs(client *http.Client, st *stateFile) ([]job, error) {
req, _ := http.NewRequest(http.MethodGet, st.PanelURL+"/api/agent/jobs", nil)
req.Header.Set("Authorization", "Bearer "+st.AgentToken)
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if res.StatusCode >= 300 {
return nil, fmt.Errorf("%s: %s", res.Status, string(raw))
}
var jobs []job
if err := json.Unmarshal(raw, &jobs); err != nil {
return nil, err
}
return jobs, nil
}
func postResult(client *http.Client, st *stateFile, id string, ok bool, result any, errMsg string) error {
payload := map[string]any{"ok": ok, "result": result, "error": errMsg}
b, _ := json.Marshal(payload)
req, _ := http.NewRequest(http.MethodPost, st.PanelURL+"/api/agent/jobs/"+id+"/result", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+st.AgentToken)
req.Header.Set("Content-Type", "application/json")
res, err := client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
return nil
}
func doTelemtProxy(client *http.Client, base, auth string, payload map[string]any) (any, error) {
method, _ := payload["method"].(string)
if method == "" {
method = "GET"
}
path, _ := payload["path"].(string)
if path == "" {
return nil, fmt.Errorf("missing path")
}
url := stringsTrimSlash(base) + path
var body io.Reader
if payload["body"] != nil && method != "GET" && method != "DELETE" {
b, _ := json.Marshal(payload["body"])
body = bytes.NewReader(b)
}
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
if auth != "" {
req.Header.Set("Authorization", auth)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
var out any
if json.Unmarshal(raw, &out) != nil {
out = map[string]any{"ok": false, "raw": string(raw), "status": res.StatusCode}
}
return out, nil
}
func stringsTrimSlash(s string) string {
for len(s) > 0 && s[len(s)-1] == '/' {
s = s[:len(s)-1]
}
return s
}
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@telemt/api",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsup src/server.ts --format esm --dts",
"start": "node dist/server.js",
"bootstrap-admin": "tsx src/scripts/bootstrap-admin.ts",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@fastify/cors": "^11.0.1",
"@fastify/helmet": "^13.0.1",
"@fastify/jwt": "^9.1.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.3",
"@fastify/static": "^8.2.0",
"@node-rs/argon2": "^2.0.2",
"@telemt/db": "workspace:*",
"@telemt/shared": "workspace:*",
"drizzle-orm": "^0.44.2",
"fastify": "^5.4.0",
"fastify-plugin": "^5.0.1",
"zod": "^3.25.0"
},
"devDependencies": {
"@types/node": "^22.15.32",
"tsup": "^8.5.0",
"tsx": "^4.20.3",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
}
}
+114
View File
@@ -0,0 +1,114 @@
import type { FastifyInstance } from 'fastify'
import Fastify from 'fastify'
import cors from '@fastify/cors'
import helmet from '@fastify/helmet'
import rateLimit from '@fastify/rate-limit'
import sensible from '@fastify/sensible'
import fjwt from '@fastify/jwt'
import fastifyStatic from '@fastify/static'
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { createDb, healthCheck, migrateSchema, type AppDb, type Sqlite } from '@telemt/db'
import type { AppConfig } from './config.js'
import { TelemtClient } from './services/telemt-client.js'
import { authRoutes, ensureBootstrapAdmin } from './routes/auth.js'
import { telemtRoutes, fleetRoutes, agentProtocolRoutes } from './routes/telemt.js'
declare module 'fastify' {
interface FastifyInstance {
config: AppConfig
db: AppDb
sqlite: Sqlite
telemt: TelemtClient
}
}
export async function buildApp(opts: {
config: AppConfig
databaseUrl?: string
}): Promise<FastifyInstance> {
const config = opts.config
const dbUrl = opts.databaseUrl ?? config.databaseUrl
const path = dbUrl.replace(/^sqlite:/, '')
if (path !== ':memory:') {
mkdirSync(dirname(resolve(path)), { recursive: true })
}
const { db, sqlite } = createDb(dbUrl)
migrateSchema(sqlite)
const app = Fastify({
logger: { level: config.logLevel },
trustProxy: true,
})
const telemt = new TelemtClient(config.telemtApiUrl, config.telemtAuthHeader)
app.decorate('config', config)
app.decorate('db', db)
app.decorate('sqlite', sqlite)
app.decorate('telemt', telemt)
await app.register(sensible)
await app.register(cors, { origin: true, credentials: true })
await app.register(helmet, { contentSecurityPolicy: false })
await app.register(rateLimit, { max: 300, timeWindow: '1 minute' })
await app.register(fjwt, { secret: config.jwtSecret })
app.get('/health', async () => {
healthCheck(sqlite)
return { ok: true, panelMode: config.panelMode }
})
app.get('/ready', async () => {
healthCheck(sqlite)
const telemtOk =
config.panelMode === 'standalone' ? await telemt.health() : true
return { ok: true, telemt: telemtOk, panelMode: config.panelMode }
})
app.get('/api/v1/health', async () => ({
ok: true,
service: 'telemt-panel',
panelMode: config.panelMode,
}))
if (config.bootstrapPassword) {
await ensureBootstrapAdmin(app, config.bootstrapUsername, config.bootstrapPassword)
}
await app.register(authRoutes)
await app.register(telemtRoutes)
await app.register(fleetRoutes)
await app.register(agentProtocolRoutes)
app.get('/install-agent.sh', async (_request, reply) => {
const candidates = [
resolve(process.cwd(), 'deploy/install-agent.sh'),
resolve(import.meta.dirname, '../../../deploy/install-agent.sh'),
'/app/deploy/install-agent.sh',
]
const scriptPath = candidates.find((p) => existsSync(p))
if (!scriptPath) {
return reply.code(404).send('install-agent.sh not found')
}
reply.header('Content-Type', 'text/x-shellscript')
return readFileSync(scriptPath, 'utf-8')
})
if (config.staticDir && existsSync(config.staticDir)) {
await app.register(fastifyStatic, {
root: config.staticDir,
wildcard: false,
})
app.setNotFoundHandler((request, reply) => {
if (request.method === 'GET' && !request.url.startsWith('/api')) {
return reply.sendFile('index.html')
}
return reply.code(404).send({ error: { code: 'not_found', message: 'Not found' } })
})
}
return app
}
+45
View File
@@ -0,0 +1,45 @@
import { z } from 'zod'
import { PANEL_MODES } from '@telemt/shared'
export const configSchema = z.object({
databaseUrl: z.string().default('sqlite:data/app.db'),
jwtSecret: z.string().min(8),
jwtTtlHours: z.coerce.number().positive().default(24),
issuer: z.string().default('telemt-panel'),
serverPort: z.coerce.number().int().positive().default(8080),
staticDir: z.string().optional(),
logLevel: z.string().default('info'),
isProd: z.boolean(),
panelMode: z.enum(PANEL_MODES).default('standalone'),
telemtApiUrl: z.string().default('http://127.0.0.1:9091'),
telemtAuthHeader: z.string().default(''),
panelEncryptionKey: z.string().default(''),
panelPublicUrl: z.string().default('http://127.0.0.1:8080'),
bootstrapUsername: z.string().default('admin'),
bootstrapPassword: z.string().optional(),
})
export type AppConfig = z.infer<typeof configSchema>
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const isProd = env.NODE_ENV === 'production'
const jwtSecret = env.JWT_SECRET ?? (isProd ? '' : 'dev-secret-change-me')
return configSchema.parse({
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
jwtSecret,
jwtTtlHours: env.JWT_TTL_HOURS ?? 24,
issuer: env.ISSUER ?? 'telemt-panel',
serverPort: env.SERVER_PORT ?? 8080,
staticDir: env.STATIC_DIR || undefined,
logLevel: env.LOG_LEVEL ?? 'info',
isProd,
panelMode: env.PANEL_MODE ?? 'standalone',
telemtApiUrl: env.TELEMT_API_URL ?? 'http://127.0.0.1:9091',
telemtAuthHeader: env.TELEMT_AUTH_HEADER ?? '',
panelEncryptionKey: env.PANEL_ENCRYPTION_KEY ?? (isProd ? '' : 'dev-encryption-key'),
panelPublicUrl: env.PANEL_PUBLIC_URL ?? 'http://127.0.0.1:8080',
bootstrapUsername: env.BOOTSTRAP_USERNAME ?? 'admin',
bootstrapPassword: env.BOOTSTRAP_PASSWORD,
})
}
+44
View File
@@ -0,0 +1,44 @@
import type { FastifyReply, FastifyRequest } from 'fastify'
import { eq } from 'drizzle-orm'
import { operators } from '@telemt/db'
export interface AuthOperator {
id: string
username: string
role: string
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: { sub: string; username: string; role: string }
user: { sub: string; username: string; role: string }
}
}
export async function requireAuth(request: FastifyRequest, reply: FastifyReply) {
try {
await request.jwtVerify()
} catch {
return reply.code(401).send({ error: { code: 'unauthorized', message: 'Требуется вход' } })
}
const row = request.server.db
.select()
.from(operators)
.where(eq(operators.id, request.user.sub))
.get()
if (!row || row.disabled) {
return reply.code(401).send({ error: { code: 'unauthorized', message: 'Оператор недоступен' } })
}
;(request as FastifyRequest & { operator: AuthOperator }).operator = {
id: row.id,
username: row.username,
role: row.role,
}
}
export function getOperator(request: FastifyRequest): AuthOperator {
return (request as FastifyRequest & { operator: AuthOperator }).operator
}
+83
View File
@@ -0,0 +1,83 @@
import type { FastifyInstance } from 'fastify'
import { createHash, randomBytes, randomUUID } from 'node:crypto'
import { eq } from 'drizzle-orm'
import { hash, verify } from '@node-rs/argon2'
import { loginRequestSchema } from '@telemt/shared'
import { operators } from '@telemt/db'
import { getOperator, requireAuth } from '../plugins/auth-guards.js'
function sha256(value: string): string {
return createHash('sha256').update(value).digest('hex')
}
export async function authRoutes(app: FastifyInstance) {
app.post('/api/auth/login', async (request, reply) => {
const parsed = loginRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'bad_request', message: 'Неверные данные' } })
}
const row = app.db
.select()
.from(operators)
.where(eq(operators.username, parsed.data.username))
.get()
if (!row || row.disabled) {
return reply.code(401).send({ error: { code: 'invalid_credentials', message: 'Неверный логин или пароль' } })
}
const ok = await verify(row.passwordHash, parsed.data.password)
if (!ok) {
return reply.code(401).send({ error: { code: 'invalid_credentials', message: 'Неверный логин или пароль' } })
}
const accessToken = await reply.jwtSign(
{ sub: row.id, username: row.username, role: row.role },
{ expiresIn: `${app.config.jwtTtlHours}h` },
)
return {
accessToken,
operator: { id: row.id, username: row.username, role: row.role },
panelMode: app.config.panelMode,
}
})
app.get('/api/auth/me', { preHandler: requireAuth }, async (request) => {
const op = getOperator(request)
return { operator: op, panelMode: app.config.panelMode }
})
app.get('/api/auth/config', async () => ({
panelMode: app.config.panelMode,
issuer: app.config.issuer,
}))
}
export async function ensureBootstrapAdmin(
app: FastifyInstance,
username: string,
password: string,
): Promise<void> {
const existing = app.db.select().from(operators).all()
if (existing.length > 0) return
const now = new Date().toISOString()
const passwordHash = await hash(password)
app.db
.insert(operators)
.values({
id: randomUUID(),
username,
passwordHash,
role: 'admin',
disabled: false,
createdAt: now,
updatedAt: now,
})
.run()
app.log.info(`bootstrap admin created: ${username}`)
}
export { sha256, randomBytes }
+378
View File
@@ -0,0 +1,378 @@
import type { FastifyInstance } from 'fastify'
import { randomUUID } from 'node:crypto'
import { eq, and, asc, isNull } from 'drizzle-orm'
import { telemtProxyRequestSchema } from '@telemt/shared'
import { agents, jobs, enrollmentTokens, managedClients } from '@telemt/db'
import { requireAuth, getOperator } from '../plugins/auth-guards.js'
import { sha256, randomBytes } from './auth.js'
export async function telemtRoutes(app: FastifyInstance) {
app.all('/api/telemt/*', { preHandler: requireAuth }, async (request, reply) => {
const suffix = (request.params as { '*': string })['*']
const path = `/v1/${suffix}`
const method = request.method.toUpperCase()
if (app.config.panelMode === 'standalone') {
const ifMatch = request.headers['if-match']
try {
const { status, envelope } = await app.telemt.request({
method,
path,
body: method === 'GET' || method === 'DELETE' ? undefined : request.body,
ifMatch: typeof ifMatch === 'string' ? ifMatch : undefined,
})
return reply.code(status).send(envelope)
} catch (err) {
const message = err instanceof Error ? err.message : 'Telemt unreachable'
return reply.code(502).send({
ok: false,
error: { code: 'telemt_unreachable', message },
})
}
}
return reply.code(400).send({
error: {
code: 'use_server_proxy',
message: 'В режиме fleet используйте /api/servers/:id/telemt/*',
},
})
})
app.post('/api/telemt/proxy', { preHandler: requireAuth }, async (request, reply) => {
const parsed = telemtProxyRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.code(400).send({ error: { code: 'bad_request', message: 'Неверный запрос' } })
}
if (app.config.panelMode !== 'standalone') {
return reply.code(400).send({
error: { code: 'fleet_mode', message: 'Proxy только для standalone' },
})
}
try {
const { status, envelope } = await app.telemt.request(parsed.data)
return reply.code(status).send(envelope)
} catch (err) {
const message = err instanceof Error ? err.message : 'Telemt unreachable'
return reply.code(502).send({
ok: false,
error: { code: 'telemt_unreachable', message },
})
}
})
}
export async function fleetRoutes(app: FastifyInstance) {
app.get('/api/servers', { preHandler: requireAuth }, async () => {
if (app.config.panelMode === 'standalone') {
const reachable = await app.telemt.health()
return [
{
id: 'local',
name: 'Local Telemt',
status: reachable ? 'online' : 'offline',
mode: 'standalone',
},
]
}
return app.db
.select()
.from(agents)
.orderBy(asc(agents.name))
.all()
.map((r) => ({
id: r.id,
name: r.name,
status: r.status,
lastSeenAt: r.lastSeenAt,
hostname: r.hostname,
agentVersion: r.agentVersion,
mode: 'fleet',
}))
})
app.all('/api/servers/:id/telemt/*', { preHandler: requireAuth }, async (request, reply) => {
const { id } = request.params as { id: string }
const suffix = (request.params as { id: string; '*': string })['*']
const path = `/v1/${suffix}`
const method = request.method.toUpperCase()
if (app.config.panelMode === 'standalone' || id === 'local') {
const ifMatch = request.headers['if-match']
try {
const { status, envelope } = await app.telemt.request({
method,
path,
body: method === 'GET' || method === 'DELETE' ? undefined : request.body,
ifMatch: typeof ifMatch === 'string' ? ifMatch : undefined,
})
return reply.code(status).send(envelope)
} catch (err) {
const message = err instanceof Error ? err.message : 'Telemt unreachable'
return reply.code(502).send({
ok: false,
error: { code: 'telemt_unreachable', message },
})
}
}
const agent = app.db.select().from(agents).where(eq(agents.id, id)).get()
if (!agent) {
return reply.code(404).send({ error: { code: 'not_found', message: 'Сервер не найден' } })
}
const now = new Date().toISOString()
const jobId = randomUUID()
app.db
.insert(jobs)
.values({
id: jobId,
agentId: id,
type: 'telemt.proxy',
payloadJson: JSON.stringify({
method,
path,
body: method === 'GET' || method === 'DELETE' ? undefined : request.body,
}),
status: 'pending',
createdAt: now,
updatedAt: now,
})
.run()
const deadline = Date.now() + 15_000
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 200))
const job = app.db.select().from(jobs).where(eq(jobs.id, jobId)).get()
if (!job) break
if (job.status === 'done' && job.resultJson) {
try {
return reply.send(JSON.parse(job.resultJson))
} catch {
return reply.send({ ok: true, data: job.resultJson })
}
}
if (job.status === 'error') {
return reply.code(502).send({
error: { code: 'agent_error', message: job.error ?? 'Ошибка агента' },
})
}
}
return reply.code(504).send({
error: { code: 'agent_timeout', message: 'Агент не ответил вовремя', jobId },
})
})
app.get('/api/enrollment-tokens', { preHandler: requireAuth }, async (_request, reply) => {
if (app.config.panelMode !== 'fleet') {
return reply.code(400).send({ error: { code: 'standalone', message: 'Только в режиме fleet' } })
}
return app.db
.select()
.from(enrollmentTokens)
.all()
.map((t) => ({
id: t.id,
label: t.label,
expiresAt: t.expiresAt,
usedAt: t.usedAt,
createdAt: t.createdAt,
}))
})
app.post('/api/enrollment-tokens', { preHandler: requireAuth }, async (request, reply) => {
if (app.config.panelMode !== 'fleet') {
return reply.code(400).send({ error: { code: 'standalone', message: 'Только в режиме fleet' } })
}
const body = (request.body ?? {}) as { label?: string; ttlHours?: number }
const token = randomBytes(24).toString('hex')
const now = new Date()
const expires = new Date(now.getTime() + (body.ttlHours ?? 24) * 3600_000)
const id = randomUUID()
app.db
.insert(enrollmentTokens)
.values({
id,
tokenHash: sha256(token),
label: body.label ?? 'default',
expiresAt: expires.toISOString(),
createdAt: now.toISOString(),
})
.run()
const op = getOperator(request)
return {
id,
token,
expiresAt: expires.toISOString(),
installCommand: `curl -fsSL ${app.config.panelPublicUrl}/install-agent.sh | sudo bash -s -- --panel-url ${app.config.panelPublicUrl} --token ${token}`,
createdBy: op.username,
}
})
app.get('/api/clients', { preHandler: requireAuth }, async () => {
return app.db.select().from(managedClients).all()
})
app.post('/api/clients', { preHandler: requireAuth }, async (request, reply) => {
const body = (request.body ?? {}) as { username?: string; secret?: string }
if (!body.username) {
return reply.code(400).send({ error: { code: 'bad_request', message: 'username обязателен' } })
}
const now = new Date().toISOString()
const id = randomUUID()
app.db
.insert(managedClients)
.values({
id,
username: body.username,
secret: body.secret ?? null,
metaJson: '{}',
createdAt: now,
updatedAt: now,
})
.run()
if (app.config.panelMode === 'standalone') {
const { status, envelope } = await app.telemt.request({
method: 'POST',
path: '/v1/users',
body: { username: body.username, ...(body.secret ? { secret: body.secret } : {}) },
})
return reply.code(status).send({ local: { id, username: body.username }, telemt: envelope })
}
return { id, username: body.username }
})
}
export async function agentProtocolRoutes(app: FastifyInstance) {
app.post('/api/agent/enroll', async (request, reply) => {
if (app.config.panelMode !== 'fleet') {
return reply.code(400).send({ error: { code: 'standalone', message: 'Enrollment только в fleet' } })
}
const body = (request.body ?? {}) as {
token?: string
name?: string
hostname?: string
agentVersion?: string
}
if (!body.token) {
return reply.code(400).send({ error: { code: 'bad_request', message: 'token обязателен' } })
}
const tokenHash = sha256(body.token)
const now = new Date().toISOString()
const row = app.db
.select()
.from(enrollmentTokens)
.where(and(eq(enrollmentTokens.tokenHash, tokenHash), isNull(enrollmentTokens.usedAt)))
.get()
if (!row || row.expiresAt < now) {
return reply.code(401).send({ error: { code: 'invalid_token', message: 'Токен недействителен' } })
}
const agentId = randomUUID()
const agentToken = randomBytes(32).toString('hex')
app.db
.insert(agents)
.values({
id: agentId,
name: body.name ?? body.hostname ?? `agent-${agentId.slice(0, 8)}`,
status: 'online',
tokenHash: sha256(agentToken),
lastSeenAt: now,
agentVersion: body.agentVersion ?? null,
hostname: body.hostname ?? null,
createdAt: now,
updatedAt: now,
})
.run()
app.db
.update(enrollmentTokens)
.set({ usedAt: now })
.where(eq(enrollmentTokens.id, row.id))
.run()
return { agentId, agentToken, panelUrl: app.config.panelPublicUrl }
})
app.get('/api/agent/jobs', async (request, reply) => {
const auth = request.headers.authorization ?? ''
const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''
if (!token) return reply.code(401).send({ error: { code: 'unauthorized' } })
const agent = app.db
.select()
.from(agents)
.where(eq(agents.tokenHash, sha256(token)))
.get()
if (!agent) return reply.code(401).send({ error: { code: 'unauthorized' } })
const now = new Date().toISOString()
app.db
.update(agents)
.set({ lastSeenAt: now, status: 'online', updatedAt: now })
.where(eq(agents.id, agent.id))
.run()
const pending = app.db
.select()
.from(jobs)
.where(and(eq(jobs.agentId, agent.id), eq(jobs.status, 'pending')))
.all()
for (const j of pending) {
app.db
.update(jobs)
.set({ status: 'running', updatedAt: now })
.where(eq(jobs.id, j.id))
.run()
}
return pending.map((j) => ({
id: j.id,
type: j.type,
payload: JSON.parse(j.payloadJson || '{}'),
}))
})
app.post('/api/agent/jobs/:id/result', async (request, reply) => {
const auth = request.headers.authorization ?? ''
const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''
if (!token) return reply.code(401).send({ error: { code: 'unauthorized' } })
const agent = app.db
.select()
.from(agents)
.where(eq(agents.tokenHash, sha256(token)))
.get()
if (!agent) return reply.code(401).send({ error: { code: 'unauthorized' } })
const { id } = request.params as { id: string }
const job = app.db.select().from(jobs).where(eq(jobs.id, id)).get()
if (!job || job.agentId !== agent.id) {
return reply.code(404).send({ error: { code: 'not_found' } })
}
const body = (request.body ?? {}) as { ok?: boolean; result?: unknown; error?: string }
const now = new Date().toISOString()
app.db
.update(jobs)
.set({
status: body.ok === false ? 'error' : 'done',
resultJson: body.result !== undefined ? JSON.stringify(body.result) : null,
error: body.error ?? null,
updatedAt: now,
})
.where(eq(jobs.id, id))
.run()
return { ok: true }
})
}
+64
View File
@@ -0,0 +1,64 @@
import { readFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { hash } from '@node-rs/argon2'
import { randomUUID } from 'node:crypto'
import { createDb, migrateSchema, operators } from '@telemt/db'
import { eq } from 'drizzle-orm'
import { loadConfig } from '../config.js'
for (const path of [resolve(import.meta.dirname, '../../../../.env'), '.env']) {
if (!existsSync(path)) continue
const content = readFileSync(path, 'utf-8')
for (const line of content.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eqIdx = trimmed.indexOf('=')
if (eqIdx === -1) continue
const key = trimmed.slice(0, eqIdx).trim()
let value = trimmed.slice(eqIdx + 1).trim()
if (!(key in process.env)) process.env[key] = value
}
break
}
const args = process.argv.slice(2)
function flag(name: string): string | undefined {
const i = args.indexOf(`--${name}`)
return i >= 0 ? args[i + 1] : undefined
}
const username = flag('username') ?? process.env.BOOTSTRAP_USERNAME ?? 'admin'
const password = flag('password') ?? process.env.BOOTSTRAP_PASSWORD
if (!password) {
console.error('Usage: pnpm --filter api bootstrap-admin -- --username admin --password <pass>')
process.exit(1)
}
const config = loadConfig()
const { db, sqlite } = createDb(config.databaseUrl)
migrateSchema(sqlite)
const existing = db.select().from(operators).where(eq(operators.username, username)).get()
const now = new Date().toISOString()
const passwordHash = await hash(password)
if (existing) {
db.update(operators)
.set({ passwordHash, updatedAt: now, disabled: false })
.where(eq(operators.id, existing.id))
.run()
console.log(`updated operator: ${username}`)
} else {
db.insert(operators)
.values({
id: randomUUID(),
username,
passwordHash,
role: 'admin',
disabled: false,
createdAt: now,
updatedAt: now,
})
.run()
console.log(`created operator: ${username}`)
}
+43
View File
@@ -0,0 +1,43 @@
import { readFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { buildApp } from './app.js'
import { loadConfig } from './config.js'
for (const path of [
resolve(import.meta.dirname, '../../../.env'),
resolve(import.meta.dirname, '../../../.env.local'),
'.env',
'../.env',
]) {
if (!existsSync(path)) continue
const content = readFileSync(path, 'utf-8')
for (const line of content.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eq = trimmed.indexOf('=')
if (eq === -1) continue
const key = trimmed.slice(0, eq).trim()
let value = trimmed.slice(eq + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
if (!(key in process.env)) process.env[key] = value
}
break
}
const config = loadConfig()
const app = await buildApp({ config })
try {
await app.listen({ port: config.serverPort, host: '0.0.0.0' })
app.log.info(
`telemt-panel listening on ${config.serverPort} (mode=${config.panelMode})`,
)
} catch (err) {
app.log.error(err)
process.exit(1)
}
+57
View File
@@ -0,0 +1,57 @@
export interface TelemtEnvelope<T = unknown> {
ok: boolean
data?: T
revision?: string
error?: { code: string; message: string }
request_id?: number
}
export class TelemtClient {
constructor(
private readonly baseUrl: string,
private readonly authHeader: string,
) {}
async request<T = unknown>(opts: {
method?: string
path: string
body?: unknown
ifMatch?: string
}): Promise<{ status: number; envelope: TelemtEnvelope<T>; raw: string }> {
const path = opts.path.startsWith('/') ? opts.path : `/${opts.path}`
const url = `${this.baseUrl.replace(/\/$/, '')}${path}`
const headers: Record<string, string> = {
Accept: 'application/json',
}
if (this.authHeader) headers.Authorization = this.authHeader
if (opts.body !== undefined) headers['Content-Type'] = 'application/json'
if (opts.ifMatch) headers['If-Match'] = opts.ifMatch
const res = await fetch(url, {
method: opts.method ?? 'GET',
headers,
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
})
const raw = await res.text()
let envelope: TelemtEnvelope<T>
try {
envelope = JSON.parse(raw) as TelemtEnvelope<T>
} catch {
envelope = {
ok: false,
error: { code: 'bad_response', message: raw.slice(0, 200) || res.statusText },
}
}
return { status: res.status, envelope, raw }
}
async health(): Promise<boolean> {
try {
const { status, envelope } = await this.request({ path: '/v1/health' })
return status === 200 && envelope.ok === true
} catch {
return false
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"noEmit": false,
"declaration": true,
"module": "ESNext",
"moduleResolution": "bundler",
"types": ["node"],
"lib": ["ES2022"]
},
"include": ["src"]
}
+6
View File
@@ -0,0 +1,6 @@
# Frontend (Vite)
# VITE_API_URL=
# ReUI PRO license (blocks / premium registry). Free components work without it.
# https://reui.io/docs/license-setup
REUI_LICENSE_KEY=
+28
View File
@@ -0,0 +1,28 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true
},
"iconLibrary": "lucide",
"registries": {
"@reui": {
"url": "https://reui.io/r/{style}/{name}.json",
"headers": {
"Authorization": "Bearer ${REUI_LICENSE_KEY}"
}
}
},
"aliases": {
"components": "@/components",
"hooks": "@/hooks",
"lib": "@/lib",
"utils": "@telemt/ui/lib/utils",
"ui": "@telemt/ui/components"
}
}
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ru" suppressHydrationWarning>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Telemt Panel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
{
"name": "@telemt/web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -p tsconfig.json --noEmit && vite build",
"preview": "vite preview",
"lint": "eslint .",
"test": "vitest run"
},
"dependencies": {
"@base-ui/react": "^1.0.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^3.10.0",
"@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2",
"@tanstack/react-router": "^1.130.2",
"@tanstack/react-router-devtools": "^1.130.2",
"@tanstack/react-table": "^8.21.3",
"@tanstack/react-virtual": "^3.14.4",
"@telemt/shared": "workspace:*",
"@telemt/ui": "workspace:*",
"class-variance-authority": "^0.7.1",
"cmdk": "^1.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^0.468.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-day-picker": "^10.0.1",
"react-dom": "^19.2.0",
"react-hook-form": "^7.60.0",
"recharts": "3.8.0",
"sonner": "^1.7.0",
"zod": "^3.25.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.0",
"@tanstack/router-plugin": "^1.130.0",
"@types/node": "^22.10.0",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"happy-dom": "^18.0.0",
"tailwindcss": "^4.1.0",
"tw-animate-css": "^1.0.0",
"typescript": "^5.9.2",
"vite": "^7.3.1",
"vitest": "^3.0.0"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,35 @@
import { cn } from "@telemt/ui/lib/utils"
import { Item, ItemMedia } from "@telemt/ui/components/item"
export function AuthLogo({ className }: { className?: string }) {
return (
<Item
variant="outline"
className={cn(
"p-0",
"text-primary flex size-8 shrink-0 items-center justify-center",
className
)}
aria-hidden="true"
>
<ItemMedia variant="icon" className="size-auto">
<svg
width="50"
height="50"
viewBox="25.668 25.1352 49.6644 50"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="size-4"
>
<circle cx="70.634" cy="29.8334" r="4.69799" fill="currentColor" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M25.668 57.0144V29.8332C25.668 27.2386 27.7713 25.1352 30.366 25.1352C32.9606 25.1352 35.0639 27.2386 35.0639 29.8332V57.0144C35.0639 61.833 38.9702 65.7392 43.7888 65.7392H57.2116C62.0302 65.7392 65.9364 61.833 65.9364 57.0144V43.7258C65.9364 41.1312 68.0398 39.0278 70.6344 39.0278C73.229 39.0278 75.3324 41.1312 75.3324 43.7258V57.0144C75.3324 67.0222 67.2194 75.1352 57.2116 75.1352H43.7888C33.7809 75.1352 25.668 67.0222 25.668 57.0144Z"
fill="currentColor"
/>
</svg>
</ItemMedia>
</Item>
)
}
@@ -0,0 +1,180 @@
import * as React from "react"
import {
Frame,
FrameDescription,
FramePanel,
FrameTitle,
} from "@/components/reui/frame"
import { Button } from "@telemt/ui/components/button"
import { Field, FieldGroup, FieldLabel } from "@telemt/ui/components/field"
import { Input } from "@telemt/ui/components/input"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "@telemt/ui/components/input-group"
import { Separator } from "@telemt/ui/components/separator"
import { AuthLogo } from "./auth-logo"
import { AUTH4_FOOTER_LINKS, AUTH4_PROVIDERS } from "./data"
import { EyeOffIcon, EyeIcon } from "lucide-react"
type FormSubmitHandler = NonNullable<React.ComponentProps<"form">["onSubmit"]>
type FormSubmitEvent = Parameters<FormSubmitHandler>[0]
function FooterMenu() {
return (
<footer className="relative z-10 bg-transparent">
<div className="mx-auto flex max-w-4xl flex-col items-center justify-center gap-4 px-6 py-4 text-center sm:flex-row sm:flex-wrap sm:gap-x-9 sm:gap-y-2">
<nav
aria-label="Footer"
className="flex flex-wrap items-center justify-center gap-x-4 gap-y-2"
>
{AUTH4_FOOTER_LINKS.map((link) => (
<a
key={link.id}
href={link.href}
className="text-muted-foreground hover:text-primary cursor-pointer text-sm underline-offset-4 transition-colors hover:underline"
>
{link.label}
</a>
))}
</nav>
</div>
</footer>
)
}
export function Auth() {
const [showPassword, setShowPassword] = React.useState(false)
function handleSubmit(event: FormSubmitEvent) {
event.preventDefault()
}
return (
<div className="relative flex min-h-svh w-full min-w-full flex-col">
<div className="flex w-full min-w-full flex-1 items-center justify-center px-4 py-8 sm:px-6 sm:py-10 lg:px-10">
<div className="mx-auto flex w-full max-w-[28rem] flex-col gap-4">
<Frame spacing="lg" className="w-full">
<FramePanel className="space-y-8 px-9 py-10 sm:space-y-9 sm:px-11 sm:py-11">
<div className="flex flex-col items-center gap-5 pt-3 text-center">
<AuthLogo className="size-9" />
<div className="flex max-w-xs flex-col gap-1.5">
<FrameTitle className="text-2xl tracking-tight sm:text-[1.75rem]">
Sign in
</FrameTitle>
<FrameDescription className="text-sm text-pretty">
Continue to your workspace.
</FrameDescription>
</div>
</div>
<form className="flex flex-col gap-5" onSubmit={handleSubmit}>
<FieldGroup className="gap-4">
<Field className="gap-2">
<FieldLabel htmlFor="auth-4-email">
Email or username
</FieldLabel>
<Input
id="auth-4-email"
type="text"
autoComplete="username"
placeholder="Email or username"
/>
</Field>
<Field className="gap-2">
<div className="flex items-center justify-between gap-3">
<FieldLabel htmlFor="auth-4-password">
Password
</FieldLabel>
<Button
type="button"
variant="link"
className="text-muted-foreground hover:text-foreground h-auto p-0 text-xs font-normal"
>
Forgot password?
</Button>
</div>
<InputGroup className="w-full">
<InputGroupInput
id="auth-4-password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
placeholder="Enter your password"
/>
<InputGroupAddon align="inline-end">
<InputGroupButton
type="button"
size="icon-xs"
className="text-muted-foreground hover:text-foreground"
aria-label={
showPassword ? "Hide password" : "Show password"
}
aria-pressed={showPassword}
onClick={() => setShowPassword((value) => !value)}
>
{showPassword ? (
<EyeOffIcon aria-hidden="true" className="size-4" />
) : (
<EyeIcon aria-hidden="true" className="size-4" />
)}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
</Field>
</FieldGroup>
<Button type="submit" className="w-full">
Sign in
</Button>
</form>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Separator className="flex-1" />
<span className="text-muted-foreground text-xs">
Or continue with
</span>
<Separator className="flex-1" />
</div>
<div className="grid gap-3 sm:grid-cols-3">
{AUTH4_PROVIDERS.map((provider) => (
<Button
key={provider.id}
type="button"
variant="outline"
className="w-full justify-center"
>
{provider.logo}
{provider.label}
</Button>
))}
</div>
</div>
</FramePanel>
</Frame>
<p className="text-muted-foreground text-center text-sm">
Need an account?{" "}
<Button
type="button"
variant="link"
className="text-primary hover:text-primary h-auto cursor-pointer p-0 underline-offset-4 hover:underline"
>
Sign up
</Button>
</p>
</div>
</div>
{/* Footer */}
<FooterMenu />
</div>
)
}
@@ -0,0 +1,78 @@
import type { ReactNode } from "react"
import { Apple } from "@/components/ui/svgs/apple"
import { AppleDark } from "@/components/ui/svgs/appleDark"
import { GithubDark } from "@/components/ui/svgs/githubDark"
import { GithubLight } from "@/components/ui/svgs/githubLight"
import { Google } from "@/components/ui/svgs/google"
export type AuthProvider = {
id: string
label: string
logo: ReactNode
}
export type FooterLink = {
id: string
label: string
href: string
}
function ThemeLogo({ light, dark }: { light: ReactNode; dark: ReactNode }) {
return (
<>
<span aria-hidden="true" className="dark:hidden">
{light}
</span>
<span aria-hidden="true" className="hidden dark:block">
{dark}
</span>
</>
)
}
export const AUTH4_PROVIDERS: AuthProvider[] = [
{
id: "google",
label: "Google",
logo: <Google aria-hidden="true" data-icon="inline-start" />,
},
{
id: "apple",
label: "Apple",
logo: (
<ThemeLogo
light={<Apple aria-hidden="true" data-icon="inline-start" />}
dark={<AppleDark aria-hidden="true" data-icon="inline-start" />}
/>
),
},
{
id: "github",
label: "GitHub",
logo: (
<ThemeLogo
light={<GithubLight aria-hidden="true" data-icon="inline-start" />}
dark={<GithubDark aria-hidden="true" data-icon="inline-start" />}
/>
),
},
]
export const AUTH4_FOOTER_LINKS: FooterLink[] = [
{
id: "privacy",
label: "Privacy",
href: "#",
},
{
id: "terms",
label: "Terms",
href: "#",
},
{
id: "status",
label: "Status",
href: "#",
},
]
@@ -0,0 +1,57 @@
"use client"
import { useId, type ComponentProps } from "react"
import { cn } from "@telemt/ui/lib/utils"
export interface NoiseTextureProps extends ComponentProps<"svg"> {
className?: string
frequency?: number
octaves?: number
slope?: number
noiseOpacity?: number
}
export const NoiseTexture = ({
className,
frequency = 0.4,
octaves = 6,
slope = 0.15,
noiseOpacity = 0.6,
...props
}: NoiseTextureProps) => {
const filterId = useId()
return (
<svg
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 z-0 h-full w-full opacity-50 select-none dark:opacity-[0.75]",
className
)}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<filter id={filterId}>
<feTurbulence
type="fractalNoise"
baseFrequency={frequency}
numOctaves={octaves}
stitchTiles="stitch"
/>
<feColorMatrix type="saturate" values="0" />
<feComponentTransfer>
<feFuncR type="linear" slope={slope} />
<feFuncG type="linear" slope={slope} />
<feFuncB type="linear" slope={slope} />
</feComponentTransfer>
</filter>
<rect
width="100%"
height="100%"
filter={`url(#${filterId})`}
opacity={noiseOpacity}
/>
</svg>
)
}
@@ -0,0 +1,51 @@
import * as React from "react"
import { useId } from "react"
import { cn } from "@telemt/ui/lib/utils"
interface StripedPatternProps extends React.SVGProps<SVGSVGElement> {
direction?: "left" | "right"
}
export function StripedPattern({
direction = "left",
className,
width = 10,
height = 10,
...props
}: StripedPatternProps) {
const id = useId()
const w = Number(width)
const h = Number(height)
return (
<svg
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-0 z-10 h-full w-full stroke-[0.5]",
className
)}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<defs>
<pattern id={id} width={w} height={h} patternUnits="userSpaceOnUse">
{direction === "left" ? (
<>
<line x1="0" y1={h} x2={w} y2="0" stroke="currentColor" />
<line x1={-w} y1={h} x2="0" y2="0" stroke="currentColor" />
<line x1={w} y1={h} x2={w * 2} y2="0" stroke="currentColor" />
</>
) : (
<>
<line x1="0" y1="0" x2={w} y2={h} stroke="currentColor" />
<line x1={-w} y1="0" x2="0" y2={h} stroke="currentColor" />
<line x1={w} y1="0" x2={w * 2} y2={h} stroke="currentColor" />
</>
)}
</pattern>
</defs>
<rect width="100%" height="100%" fill={`url(#${id})`} />
</svg>
)
}
@@ -0,0 +1,25 @@
import { Auth } from "./components/auth"
import { NoiseTexture } from "./components/noise-texture"
export function Page() {
return (
<div className="bg-background relative min-h-svh w-full min-w-full overflow-hidden">
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 overflow-hidden"
>
<NoiseTexture
className="text-foreground/[0.015] dark:text-foreground/[0.03]"
frequency={0.5}
octaves={5}
slope={0.08}
noiseOpacity={0.28}
/>
</div>
<div className="relative z-10 min-h-svh min-w-full">
<Auth />
</div>
</div>
)
}
@@ -0,0 +1,48 @@
export type ToolbarOption<T extends string> = {
value: T
label: string
}
export const EXPORT_AUDIENCE_OPTIONS = [
{ value: "everyone", label: "Everyone" },
{ value: "ops-leads", label: "Ops leads" },
{ value: "finance-reviewers", label: "Finance reviewers" },
{ value: "client-owners", label: "Client owners" },
] as const satisfies readonly ToolbarOption<string>[]
export const EXPORT_SCOPE_OPTIONS = [
{ value: "all-workspaces", label: "All workspaces" },
{ value: "harbor-field", label: "Harbor field" },
{ value: "market-lab", label: "Market lab" },
{ value: "support-desk", label: "Support desk" },
] as const satisfies readonly ToolbarOption<string>[]
export const EXPORT_RANGE_OPTIONS = [
{ value: "last-30-days", label: "Last 30 days" },
{ value: "this-quarter", label: "This quarter" },
{ value: "previous-cycle", label: "Previous cycle" },
{ value: "custom-window", label: "Custom window" },
] as const satisfies readonly ToolbarOption<string>[]
export const EXPORT_FORMAT_OPTIONS = [
{
value: "csv",
label: "CSV bundle",
description: "Spreadsheet-ready activity rows",
},
{
value: "pdf",
label: "PDF brief",
description: "A concise review packet for stakeholders",
},
{
value: "schedule",
label: "Schedule delivery",
description: "Send this export every Friday morning",
},
] as const
export type ExportAudience = (typeof EXPORT_AUDIENCE_OPTIONS)[number]["value"]
export type ExportScope = (typeof EXPORT_SCOPE_OPTIONS)[number]["value"]
export type ExportRange = (typeof EXPORT_RANGE_OPTIONS)[number]["value"]
export type ExportFormat = (typeof EXPORT_FORMAT_OPTIONS)[number]["value"]
@@ -0,0 +1,247 @@
import { useState } from "react"
import { IconStack } from "@/components/reui/icon-stack"
import { toast } from "sonner"
import { Button } from "@telemt/ui/components/button"
import {
ButtonGroup,
ButtonGroupSeparator,
} from "@telemt/ui/components/button-group"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@telemt/ui/components/dropdown-menu"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@telemt/ui/components/empty"
import { Separator } from "@telemt/ui/components/separator"
import {
EXPORT_AUDIENCE_OPTIONS,
EXPORT_FORMAT_OPTIONS,
EXPORT_RANGE_OPTIONS,
EXPORT_SCOPE_OPTIONS,
type ExportAudience,
type ExportFormat,
type ExportRange,
type ExportScope,
type ToolbarOption,
} from "./data"
import { ChevronDownIcon, PlayIcon, FileDownIcon, BookOpenIcon, CalendarClockIcon, ArchiveIcon } from "lucide-react"
type ToolbarFilterProps<T extends string> = {
label: string
value: T
options: readonly ToolbarOption<T>[]
onValueChange: (value: T) => void
}
function getOptionLabel<T extends string>(
options: readonly ToolbarOption<T>[],
value: T
) {
return options.find((option) => option.value === value)?.label ?? value
}
function ToolbarFilter<T extends string>({
label,
value,
options,
onValueChange,
}: ToolbarFilterProps<T>) {
const selectedLabel = getOptionLabel(options, value)
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="outline"
size="sm"
aria-label={`${label}: ${selectedLabel}`}
>
<span className="truncate">{selectedLabel}</span>
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
</Button>
}
/>
<DropdownMenuContent align="start" className="min-w-44">
<DropdownMenuRadioGroup
value={value}
onValueChange={(nextValue) => {
if (nextValue !== null) {
onValueChange(nextValue as T)
}
}}
>
{options.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
closeOnClick
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function EmptyState() {
const [audience, setAudience] = useState<ExportAudience>(
EXPORT_AUDIENCE_OPTIONS[0].value
)
const [scope, setScope] = useState<ExportScope>(EXPORT_SCOPE_OPTIONS[0].value)
const [range, setRange] = useState<ExportRange>(EXPORT_RANGE_OPTIONS[0].value)
const audienceLabel = getOptionLabel(EXPORT_AUDIENCE_OPTIONS, audience)
const scopeLabel = getOptionLabel(EXPORT_SCOPE_OPTIONS, scope)
const rangeLabel = getOptionLabel(EXPORT_RANGE_OPTIONS, range)
const showExportToast = (format: ExportFormat = "csv") => {
const formatOption =
EXPORT_FORMAT_OPTIONS.find((option) => option.value === format) ??
EXPORT_FORMAT_OPTIONS[0]
toast.message(`${formatOption.label} is ready to wire`, {
description: `${formatOption.description}. ${audienceLabel} · ${scopeLabel} · ${rangeLabel}. Connect this action to your export job when activity records exist.`,
})
}
return (
<section
className="flex min-h-[430px] w-full max-w-4xl flex-col"
aria-labelledby="export-ledger-heading"
>
{/* Header */}
<div className="flex flex-col gap-3 pb-4 lg:flex-row lg:items-end lg:justify-between">
{/* Heading */}
<div className="flex min-w-0 flex-col gap-5">
{/* Title and Description */}
<div className="flex flex-col gap-0.5">
{/* Title */}
<h2
id="export-ledger-heading"
className="text-2xl font-semibold tracking-tight"
>
Activity Exports
</h2>
{/* Description */}
<p className="text-muted-foreground text-sm">
Download scoped activity packets for billing review, staffing
audits, and client handoffs.
</p>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-2">
<ToolbarFilter
label="Audience"
value={audience}
options={EXPORT_AUDIENCE_OPTIONS}
onValueChange={setAudience}
/>
<ToolbarFilter
label="Workspace"
value={scope}
options={EXPORT_SCOPE_OPTIONS}
onValueChange={setScope}
/>
<ToolbarFilter
label="Date range"
value={range}
options={EXPORT_RANGE_OPTIONS}
onValueChange={setRange}
/>
</div>
</div>
{/* Download Action */}
<ButtonGroup className="w-full **:data-[slot=button]:border-r-0 sm:w-fit">
<Button
type="button"
className="flex-1 sm:flex-none"
onClick={() => showExportToast()}
>
<PlayIcon className="fill-current" data-icon="inline-start" aria-hidden="true" />
<span>Execute</span>
</Button>
<ButtonGroupSeparator className="bg-primary/72" />
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
size="icon"
className="border-primary-foreground/20 rounded-l-none border-l"
aria-label="Open download options"
/>
}
>
<ChevronDownIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={8} align="end" className="w-52">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => showExportToast("csv")}>
<FileDownIcon aria-hidden="true" />
CSV bundle
</DropdownMenuItem>
<DropdownMenuItem onClick={() => showExportToast("pdf")}>
<BookOpenIcon aria-hidden="true" />
PDF brief
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => showExportToast("schedule")}>
<CalendarClockIcon aria-hidden="true" />
Schedule delivery
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</div>
<Separator />
{/* Empty State */}
<div className="flex flex-1 items-center justify-center py-14 sm:py-16">
<Empty className="max-w-md flex-none bg-transparent p-0">
<EmptyHeader className="gap-5 text-center">
<EmptyMedia className="mb-0">
<IconStack aria-hidden="true">
<ArchiveIcon strokeWidth="1.9" aria-hidden="true" />
</IconStack>
</EmptyMedia>
{/* Empty State Content */}
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">
No exportable activity yet
</EmptyTitle>
<EmptyDescription className="max-w-sm text-sm/relaxed">
Capture approved activity and this view will assemble your next
review packet.
</EmptyDescription>
</div>
</EmptyHeader>
</Empty>
</div>
</section>
)
}
@@ -0,0 +1,15 @@
import { EmptyState } from "./components/empty-state"
export function Page() {
return (
<main
className="flex min-h-svh w-full items-center justify-center p-4 sm:p-8 md:p-10"
aria-labelledby="page-heading"
>
<h1 id="page-heading" className="sr-only">
Activity exports empty state
</h1>
<EmptyState />
</main>
)
}
@@ -0,0 +1,50 @@
import type { ReactNode } from 'react'
import { Tabs, TabsList, TabsTrigger } from '@telemt/ui/components/tabs'
import { cn } from '@telemt/ui/lib/utils'
export interface CountedLineTab {
id: string
label: string
count?: number
}
interface CountedLineTabsProps {
tabs: CountedLineTab[]
value: string
onValueChange: (value: string) => void
className?: string
listClassName?: string
children?: ReactNode
}
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
export function CountedLineTabs({
tabs,
value,
onValueChange,
className,
listClassName,
children,
}: CountedLineTabsProps) {
return (
<Tabs value={value} onValueChange={onValueChange} className={className}>
<TabsList variant="line" className={cn('gap-5', listClassName)}>
{tabs.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
>
<span>{tab.label}</span>
{tab.count !== undefined ? (
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums">
{tab.count}
</span>
) : null}
</TabsTrigger>
))}
</TabsList>
{children}
</Tabs>
)
}
@@ -0,0 +1,27 @@
import type { ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
export interface DataGridColumn<T> {
key: string
header: ReactNode
cell: (row: T, index: number) => ReactNode
icon?: LucideIcon
sortable?: boolean
sortValue?: (row: T) => string | number
/** TanStack sortingFn; для числовых sortValue — `'basic'`. */
sortingFn?: 'auto' | 'alphanumeric' | 'basic' | 'text' | 'datetime'
headerTitle?: string
className?: string
headerClassName?: string
enableHiding?: boolean
}
/** @deprecated Используйте DataGridColumn */
export type DataTableColumn<T> = DataGridColumn<T>
/** Унифицированные классы колонок для FrameDataGrid. */
export const COL = {
num: 'w-28 text-right tabular-nums',
date: 'w-32 text-right tabular-nums text-muted-foreground',
actions: 'w-24 text-right',
} as const
+111
View File
@@ -0,0 +1,111 @@
/**
* ReUI Empty + IconStack — adapted from empty-state-12.
* Preview: https://reui.io/preview/base/empty-state-12
* Docs: https://reui.io/blocks
*/
import { InboxIcon, type LucideIcon } from 'lucide-react'
import { IconStack } from '@/components/reui/icon-stack'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@telemt/ui/components/empty'
import { cn } from '@telemt/ui/lib/utils'
import type { ReactNode } from 'react'
interface EmptyStateProps {
/** Lucide icon component or pre-rendered node (legacy). */
icon?: LucideIcon | ReactNode
title: string
description?: string
action?: ReactNode
className?: string
/** Use IconStack media (empty-state-12). Default true. */
stackedIcon?: boolean
/**
* Center in available width/height (empty-state-12).
* Set false for tight panels/sheets where the parent already centers.
*/
centered?: boolean
}
function isLucideIcon(icon: LucideIcon | ReactNode): icon is LucideIcon {
if (typeof icon === 'function') return true
// lucide-react icons are forwardRef objects ({ $$typeof, render, displayName })
if (icon != null && typeof icon === 'object' && '$$typeof' in icon) {
// React elements have `props` — those are pre-rendered nodes, not components
if ('props' in icon) return false
return true
}
return false
}
export function EmptyState({
icon,
title,
description,
action,
className,
stackedIcon = true,
centered = true,
}: EmptyStateProps) {
const Icon = isLucideIcon(icon) ? icon : InboxIcon
const customIcon = icon && !isLucideIcon(icon) ? icon : null
const body = (
<Empty
className={cn(
'max-w-md flex-none border-0 bg-transparent p-0',
!centered && className,
)}
>
<EmptyHeader className="gap-5 text-center">
<EmptyMedia className="mb-0">
{customIcon ? (
<div className="text-muted-foreground">{customIcon}</div>
) : stackedIcon ? (
<IconStack aria-hidden="true" className="h-14 w-12 shrink-0">
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
</IconStack>
) : (
<span className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg [&_svg]:size-5">
<Icon aria-hidden="true" />
</span>
)}
</EmptyMedia>
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">
{title}
</EmptyTitle>
{description ? (
<EmptyDescription className="max-w-sm text-sm/relaxed">
{description}
</EmptyDescription>
) : null}
</div>
</EmptyHeader>
{action ? (
<EmptyContent className="mt-1 items-center justify-center">
{action}
</EmptyContent>
) : null}
</Empty>
)
if (!centered) return body
// empty-state-12: center in available height (parent must be flex column / stretch)
return (
<div
className={cn(
'flex w-full flex-1 items-center justify-center self-stretch py-14 sm:py-16',
className,
)}
>
{body}
</div>
)
}
@@ -0,0 +1,163 @@
import {
LayoutDashboard,
Server,
Users,
Settings,
Shield,
Activity,
} from 'lucide-react'
import type { CSSProperties, ReactNode } from 'react'
import { Link, useRouterState } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
} from '@telemt/ui/components/sidebar'
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from '@telemt/ui/components/breadcrumb'
import { Separator } from '@telemt/ui/components/separator'
import { TooltipProvider } from '@telemt/ui/components/tooltip'
import { NavUser } from '@/components/layout/nav-user'
import { api } from '@/lib/api-client'
interface NavItem {
to: string
label: string
icon: typeof LayoutDashboard
fleetOnly?: boolean
}
interface NavGroup {
label: string
items: NavItem[]
}
const NAV_GROUPS: NavGroup[] = [
{
label: 'Обзор',
items: [{ to: '/dashboard', label: 'Дашборд', icon: LayoutDashboard }],
},
{
label: 'Telemt',
items: [
{ to: '/users', label: 'Пользователи', icon: Users },
{ to: '/runtime', label: 'Runtime', icon: Activity },
{ to: '/security', label: 'Безопасность', icon: Shield },
{ to: '/servers', label: 'Серверы', icon: Server, fleetOnly: true },
{ to: '/clients', label: 'Клиенты', icon: Users, fleetOnly: true },
],
},
{
label: 'Система',
items: [{ to: '/settings', label: 'Настройки', icon: Settings }],
},
]
/** @see https://reui.io/preview/base/app-shell-12 · docs: https://reui.io/docs/blocks */
export function AppShell({ children }: { children: ReactNode }) {
const pathname = useRouterState({ select: (s) => s.location.pathname })
const config = useQuery({
queryKey: ['auth-config'],
queryFn: () => api<{ panelMode: string }>('/api/auth/config'),
})
const mode = config.data?.panelMode ?? 'standalone'
const groups = NAV_GROUPS.map((g) => ({
...g,
items: g.items.filter((i) => !i.fleetOnly || mode === 'fleet'),
})).filter((g) => g.items.length > 0)
const allItems = groups.flatMap((g) => g.items)
const activeItem =
allItems.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`)) ?? allItems[0]
return (
<TooltipProvider delay={0}>
<SidebarProvider style={{ '--sidebar-width': '240px' } as CSSProperties}>
<Sidebar collapsible="icon">
<SidebarHeader>
<div className="flex items-center gap-2 px-2 py-1.5">
<div className="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-md text-xs font-semibold">
TM
</div>
<div className="flex min-w-0 flex-col group-data-[collapsible=icon]:hidden">
<span className="truncate text-sm font-semibold">Telemt Panel</span>
<span className="text-muted-foreground truncate text-xs">{mode}</span>
</div>
</div>
</SidebarHeader>
<SidebarContent>
{groups.map((group) => (
<SidebarGroup key={group.label}>
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => {
const Icon = item.icon
const isActive =
pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
render={<Link to={item.to} />}
isActive={isActive}
tooltip={item.label}
>
<Icon className="size-4" />
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))}
</SidebarContent>
<SidebarFooter>
<NavUser />
</SidebarFooter>
</Sidebar>
<SidebarInset>
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink render={<Link to="/dashboard" />}>Telemt</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>{activeItem?.label ?? '…'}</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</header>
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</div>
</SidebarInset>
</SidebarProvider>
</TooltipProvider>
)
}
+168
View File
@@ -0,0 +1,168 @@
import { useEffect, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import {
ChevronsUpDownIcon,
LogOutIcon,
MonitorIcon,
MoonIcon,
SunIcon,
} from 'lucide-react'
import { cn } from '@telemt/ui/lib/utils'
import { Avatar, AvatarFallback } from '@telemt/ui/components/avatar'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@telemt/ui/components/dropdown-menu'
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from '@telemt/ui/components/sidebar'
import { api } from '@/lib/api-client'
import { clearToken } from '@/lib/auth'
/** Sidebar footer account menu — ReUI NavUser. @see https://reui.io/preview/base/app-shell-12 */
const THEMES = [
{
value: 'light',
label: 'Светлая',
icon: <SunIcon className="size-3.5" aria-hidden />,
},
{
value: 'dark',
label: 'Тёмная',
icon: <MoonIcon className="size-3.5" aria-hidden />,
},
{
value: 'system',
label: 'Системная',
icon: <MonitorIcon className="size-3.5" aria-hidden />,
},
] as const
function ThemeSegmentedToggle() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
}, [])
const currentTheme = mounted ? (theme ?? 'system') : 'system'
return (
<div
role="radiogroup"
aria-label="Тема"
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
>
{THEMES.map((item) => {
const isActive = currentTheme === item.value
return (
<button
key={item.value}
type="button"
role="radio"
aria-checked={isActive}
aria-label={item.label}
onClick={() => setTheme(item.value)}
className={cn(
'inline-flex size-7 items-center justify-center rounded-full transition-colors',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{item.icon}
</button>
)
})}
</div>
)
}
export function NavUser() {
const { isMobile } = useSidebar()
const navigate = useNavigate()
const me = useQuery({
queryKey: ['auth-me'],
queryFn: () =>
api<{ operator: { username: string; role: string } }>('/api/auth/me'),
})
const username = me.data?.operator.username ?? 'admin'
const initials = username.slice(0, 2).toUpperCase()
function handleLogout() {
clearToken()
void navigate({ to: '/login' })
}
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger
render={
<SidebarMenuButton
size="lg"
className="data-[popup-open]:bg-sidebar-accent data-[popup-open]:text-sidebar-accent-foreground"
/>
}
>
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{username}</span>
<span className="text-muted-foreground truncate text-xs">
{me.data?.operator.role ?? 'Local auth'}
</span>
</div>
<ChevronsUpDownIcon className="ml-auto size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--anchor-width) min-w-56 rounded-lg"
side={isMobile ? 'bottom' : 'right'}
align="end"
sideOffset={4}
>
<DropdownMenuGroup>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="size-8 rounded-lg">
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{username}</span>
<span className="text-muted-foreground truncate text-xs">Local JWT</span>
</div>
</div>
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<div className="flex items-center justify-between gap-2 px-2 py-1.5">
<span className="text-muted-foreground text-xs">Тема</span>
<ThemeSegmentedToggle />
</div>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOutIcon />
Выйти
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}
+22
View File
@@ -0,0 +1,22 @@
import type { ReactNode } from 'react'
interface PageHeaderProps {
title: string
description?: string
actions?: ReactNode
}
/** Page-level section header — etalon EvoBGP. */
export function PageHeader({ title, description, actions }: PageHeaderProps) {
return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</div>
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
</div>
)
}
+6
View File
@@ -0,0 +1,6 @@
import type { ReactNode } from 'react'
import { cn } from '@telemt/ui/lib/utils'
export function PageShell({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
}
@@ -0,0 +1,116 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { cn } from '@telemt/ui/lib/utils'
export interface DetailMetricCard {
id: string
icon: ReactNode
label: string
description: string
footer?: ReactNode
}
interface DetailPanelProps {
children: ReactNode
className?: string
}
function DetailPanelRoot({ children, className }: DetailPanelProps) {
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
}
interface DetailPanelHeaderProps {
title: string
description?: string
actions?: ReactNode
children?: ReactNode
}
function DetailPanelHeader({
title,
description,
actions,
children,
}: DetailPanelHeaderProps) {
return (
<Frame dense spacing="sm" className="w-full">
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-px">
<FrameTitle>{title}</FrameTitle>
{description ? (
<FrameDescription>{description}</FrameDescription>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{actions}
</div>
) : null}
</FrameHeader>
{children ? <FramePanel className="flex flex-col gap-4">{children}</FramePanel> : null}
</Frame>
)
}
function DetailPanelMetrics({ cards }: { cards: DetailMetricCard[] }) {
return (
<div className="@container w-full">
<div className="grid gap-4 @2xl:grid-cols-3">
{cards.map((card) => (
<Frame key={card.id} spacing="sm">
<FrameHeader className="px-1! py-1!">
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
{card.icon}
<span className="text-foreground text-sm font-medium">
{card.label}
</span>
</div>
</FrameHeader>
<FramePanel className="flex flex-col gap-2">
<p className="text-muted-foreground text-xs leading-relaxed">
{card.description}
</p>
{card.footer}
</FramePanel>
</Frame>
))}
</div>
</div>
)
}
function DetailPanelSection({
title,
description,
children,
}: {
title?: string
description?: string
children: ReactNode
}) {
return (
<section className="flex flex-col gap-3">
{title ? (
<header className="px-1">
<h2 className="text-sm font-semibold">{title}</h2>
{description ? (
<p className="text-muted-foreground text-sm">{description}</p>
) : null}
</header>
) : null}
{children}
</section>
)
}
export const DetailPanel = Object.assign(DetailPanelRoot, {
Header: DetailPanelHeader,
Metrics: DetailPanelMetrics,
Section: DetailPanelSection,
})
@@ -0,0 +1,91 @@
import type { Filter } from '@/components/reui/filters'
export function getActiveFilters(filters: Filter[]) {
return filters.filter((filter) => {
const { values } = filter
if (!values || values.length === 0) return false
if (values.every((value) => typeof value === 'string' && value.trim() === '')) {
return false
}
if (values.every((value) => value === null || value === undefined)) {
return false
}
if (values.every((value) => Array.isArray(value) && value.length === 0)) {
return false
}
return true
})
}
export function applyFiltersToData<T>(
data: T[],
filters: Filter[],
getFieldValue: (item: T, field: string) => unknown,
): T[] {
const active = getActiveFilters(filters)
let result = [...data]
for (const filter of active) {
const { field, operator, values } = filter
result = result.filter((item) => {
const raw = getFieldValue(item, field)
const fieldValue = raw != null ? raw : ''
switch (operator) {
case 'is':
return values.includes(fieldValue)
case 'is_not':
return !values.includes(fieldValue)
case 'is_any_of':
return values.some((value) => fieldValue === value)
case 'is_not_any_of':
return !values.some((value) => fieldValue === value)
case 'contains': {
const tokens = values
.map((value) => String(value).trim())
.filter(Boolean)
if (tokens.length === 0) return true
return tokens.some((token) =>
String(fieldValue).toLowerCase().includes(token.toLowerCase()),
)
}
case 'not_contains':
return !values.some((value) =>
String(fieldValue).toLowerCase().includes(String(value).toLowerCase()),
)
case 'starts_with':
return values.some((value) =>
String(fieldValue).toLowerCase().startsWith(String(value).toLowerCase()),
)
case 'ends_with':
return values.some((value) =>
String(fieldValue).toLowerCase().endsWith(String(value).toLowerCase()),
)
case 'empty':
return fieldValue === '' || fieldValue == null
case 'not_empty':
return fieldValue !== '' && fieldValue != null
default:
return true
}
})
}
return result
}
export function renderSelectedCount(values: unknown[]) {
if (values.length === 0) return 'Выберите…'
if (values.length > 1) return `${values.length} выбрано`
return null
}
export function renderSingleSelectedLabel(
values: unknown[],
options: { value: string; label: string }[],
) {
const state = renderSelectedCount(values)
if (state) return state
const option = options.find((item) => item.value === values[0])
return option?.label ?? String(values[0])
}
@@ -0,0 +1,449 @@
import { useState, useEffect, type ReactNode } from 'react'
import {
useReactTable,
getCoreRowModel,
getSortedRowModel,
getPaginationRowModel,
flexRender,
type ColumnDef,
type SortingState,
type RowSelectionState,
type VisibilityState,
type OnChangeFn,
} from '@tanstack/react-table'
import { Columns3Icon } from 'lucide-react'
import { Checkbox } from '@telemt/ui/components/checkbox'
import { Button } from '@telemt/ui/components/button'
import { cn } from '@telemt/ui/lib/utils'
import {
DataGrid,
DataGridContainer,
} from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { DataGridTableVirtual } from '@/components/reui/data-grid/data-grid-table-virtual'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
import { DataGridColumnVisibility } from '@/components/reui/data-grid/data-grid-column-visibility'
import { EmptyState } from '@/components/empty-state'
import type { DataGridColumn } from '@/components/data-grid-types'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
const PAGINATION_LABELS = {
rowsPerPageLabel: 'Строк на странице',
info: '{from}{to} из {count}',
previousPageLabel: 'Предыдущая страница',
nextPageLabel: 'Следующая страница',
pageLabel: 'Страница {page}',
previousPagesLabel: 'Предыдущие страницы',
nextPagesLabel: 'Следующие страницы',
} as const
function resolveHeaderTitle(header: ReactNode, headerTitle?: string): string {
if (headerTitle) return headerTitle
if (typeof header === 'string') return header
return ''
}
function loadStoredColumnVisibility(key: string): VisibilityState | undefined {
try {
const raw = localStorage.getItem(key)
if (!raw) return undefined
return JSON.parse(raw) as VisibilityState
} catch {
return undefined
}
}
export { loadStoredColumnVisibility }
export interface DataGridColumnVisibilityOption {
id: string
label: string
}
export function dataGridColumnVisibilityOptions<T>(
cols: DataGridColumn<T>[],
): DataGridColumnVisibilityOption[] {
return cols
.filter((c) => c.enableHiding !== false)
.map((c) => ({
id: c.key,
label: c.headerTitle ?? (typeof c.header === 'string' ? c.header : c.key),
}))
}
export interface FrameDataGridProps<TData extends object> {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
columns: ColumnDef<TData, unknown>[]
data: TData[]
/** Ключ строки — функция, возвращающая уникальный id. */
rowId?: (row: TData, index: number) => string
emptyTitle?: string
emptyDescription?: string
emptyAction?: ReactNode
onRowClick?: (row: TData) => void
/** Включить пагинацию. По умолчанию true. */
pagination?: boolean
/** Размер страницы. По умолчанию 10. */
pageSize?: number
/** Footer-контент (итоги). */
footerContent?: ReactNode
/** Плотный layout. */
dense?: boolean
/** Колонка действий закреплена справа. */
pinLastColumn?: boolean
/** Сортировка по умолчанию. */
initialSorting?: SortingState
/** Включить виртуализацию строк (для тяжёлых таблиц). Требует height. */
virtualization?: boolean
/** Высота viewport для виртуализации (px). По умолчанию 480. */
height?: number
/** Включить выбор строк (чекбоксы). */
enableRowSelection?: boolean
/** Callback при изменении выбора. */
onRowSelectionChange?: (selectedIds: string[]) => void
/** Показать picker видимости колонок. */
enableColumnVisibility?: boolean
/** Управляемая видимость колонок (для внешнего UI, напр. тулбар «Вид»). */
columnVisibility?: VisibilityState
onColumnVisibilityChange?: OnChangeFn<VisibilityState>
/** Показать встроенную кнопку «Колонки». По умолчанию true при enableColumnVisibility. */
columnVisibilityTrigger?: boolean
/** Ключ localStorage для сохранения видимости колонок. */
columnVisibilityStorageKey?: string
/** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */
initialColumnVisibility?: VisibilityState
className?: string
}
function DataGridSectionHeader({
title,
description,
actions,
}: {
title?: ReactNode
description?: ReactNode
actions?: ReactNode
}) {
if (!title && !description && !actions) return null
return (
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
{title ? <FrameTitle className="text-balance">{title}</FrameTitle> : null}
{description ? (
<FrameDescription className="text-xs text-pretty">{description}</FrameDescription>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">{actions}</div>
) : null}
</FrameHeader>
)
}
function DataGridPaginationBar() {
return (
<FrameFooter>
<DataGridPagination {...PAGINATION_LABELS} />
</FrameFooter>
)
}
function FrameDataGridBody<TData extends object>({
table,
data,
emptyTitle,
onRowClick,
dense,
virtualization,
height,
footerContent,
showPagination,
enableColumnVisibility,
}: {
table: ReturnType<typeof useReactTable<TData>>
data: TData[]
emptyTitle: string
onRowClick?: (row: TData) => void
dense: boolean
virtualization: boolean
height: number
footerContent?: ReactNode
showPagination: boolean
enableColumnVisibility: boolean
}) {
return (
<DataGrid
table={table}
recordCount={data.length}
onRowClick={onRowClick}
emptyMessage={emptyTitle}
tableLayout={{
dense,
stripped: true,
rowBorder: true,
headerSticky: true,
headerBackground: true,
headerBorder: true,
width: 'auto',
columnsVisibility: enableColumnVisibility,
columnsResizable: false,
columnsPinnable: false,
columnsMovable: false,
rowsDraggable: false,
rowsPinnable: false,
}}
tableClassNames={{
header: 'text-xs font-medium text-muted-foreground',
}}
>
<DataGridContainer border={false}>
{virtualization ? (
<DataGridScrollArea orientation="vertical" style={{ height }}>
<DataGridTableVirtual height={height} footerContent={footerContent} />
</DataGridScrollArea>
) : (
<DataGridTable footerContent={footerContent} />
)}
</DataGridContainer>
{showPagination ? <DataGridPaginationBar /> : null}
</DataGrid>
)
}
export function FrameDataGrid<TData extends object>({
title,
description,
actions,
columns,
data,
rowId,
emptyTitle = 'Нет записей',
emptyDescription,
emptyAction,
onRowClick,
pagination,
pageSize = 10,
footerContent,
dense = true,
pinLastColumn = false,
initialSorting,
virtualization = false,
height = 480,
enableRowSelection = false,
onRowSelectionChange,
enableColumnVisibility = false,
columnVisibility: columnVisibilityProp,
onColumnVisibilityChange,
columnVisibilityTrigger,
columnVisibilityStorageKey,
initialColumnVisibility,
className,
}: FrameDataGridProps<TData>) {
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [internalColumnVisibility, setInternalColumnVisibility] = useState<VisibilityState>(() => {
const stored = columnVisibilityStorageKey
? loadStoredColumnVisibility(columnVisibilityStorageKey)
: undefined
return { ...initialColumnVisibility, ...stored }
})
const isColumnVisibilityControlled = columnVisibilityProp !== undefined
const columnVisibility = isColumnVisibilityControlled ? columnVisibilityProp : internalColumnVisibility
const setColumnVisibility: OnChangeFn<VisibilityState> = isColumnVisibilityControlled
? (onColumnVisibilityChange ?? (() => undefined))
: setInternalColumnVisibility
useEffect(() => {
if (isColumnVisibilityControlled || !columnVisibilityStorageKey) return
localStorage.setItem(columnVisibilityStorageKey, JSON.stringify(columnVisibility))
}, [columnVisibility, columnVisibilityStorageKey, isColumnVisibilityControlled])
const selectColumn: ColumnDef<TData, unknown> = {
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected() && !table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Выбрать все"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Выбрать строку"
onClick={(e) => e.stopPropagation()}
/>
),
enableSorting: false,
enableHiding: false,
meta: { cellClassName: 'w-10' },
}
const tableColumns = enableRowSelection ? [selectColumn, ...columns] : columns
const lastColId = pinLastColumn ? tableColumns[tableColumns.length - 1]?.id ?? '' : ''
const showPagination = pagination ?? true
const table = useReactTable<TData>({
data,
columns: tableColumns,
state: {
sorting,
columnVisibility,
...(enableRowSelection ? { rowSelection } : {}),
},
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: enableRowSelection
? (updater) => {
setRowSelection((prev) => {
const next = typeof updater === 'function' ? updater(prev) : updater
if (onRowSelectionChange && rowId) {
const ids = Object.keys(next).filter((k) => next[k])
onRowSelectionChange(ids)
}
return next
})
}
: undefined,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: showPagination ? getPaginationRowModel() : undefined,
initialState: {
...(showPagination ? { pagination: { pageIndex: 0, pageSize } } : {}),
...(pinLastColumn && lastColId ? { columnPinning: { right: [lastColId] } } : {}),
},
getRowId: rowId
? (row, index) => rowId(row, index)
: undefined,
enableColumnPinning: pinLastColumn,
enableRowSelection,
enableHiding: enableColumnVisibility,
})
const showColumnVisibilityTrigger =
enableColumnVisibility && (columnVisibilityTrigger ?? true)
const columnVisibilityAction = showColumnVisibilityTrigger ? (
<DataGridColumnVisibility
table={table}
trigger={
<Button variant="ghost" size="sm">
<Columns3Icon data-icon="inline-start" />
Колонки
</Button>
}
/>
) : null
const headerActions = actions ? (
<div className="flex items-center gap-2">
{columnVisibilityAction}
{actions}
</div>
) : (
columnVisibilityAction
)
const hasHeader = Boolean(title || description || actions || showColumnVisibilityTrigger)
if (data.length === 0) {
return (
<Frame dense variant="default" spacing="sm" className={cn('w-full', className)}>
{hasHeader ? (
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
) : null}
<FramePanel className="flex min-h-72 w-full flex-col items-center justify-center">
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
</FramePanel>
</Frame>
)
}
const gridBody = (
<FrameDataGridBody
table={table}
data={data}
emptyTitle={emptyTitle}
onRowClick={onRowClick}
dense={dense}
virtualization={virtualization}
height={height}
footerContent={footerContent}
showPagination={showPagination}
enableColumnVisibility={enableColumnVisibility}
/>
)
return (
<Frame dense variant="default" spacing="sm" className={cn('w-full', className)}>
{hasHeader ? (
<DataGridSectionHeader title={title} description={description} actions={headerActions} />
) : null}
<FramePanel className="p-0 shadow-none!">{gridBody}</FramePanel>
</Frame>
)
}
/** Хелпер для конвертации DataGridColumn<T> → ColumnDef<T> с DataGridColumnHeader. */
export function columnDefFromDataGrid<T>(
cols: DataGridColumn<T>[],
): ColumnDef<T, unknown>[] {
return cols.map((c) => {
const title = resolveHeaderTitle(c.header, c.headerTitle)
const Icon = c.icon
const sortable = c.sortable ?? Boolean(Icon)
return {
id: c.key,
...(sortable
? {
accessorFn: c.sortValue
? (row: T) => c.sortValue!(row)
: (row: T) => (row as Record<string, unknown>)[c.key] as string | number,
sortingFn: c.sortingFn ?? 'auto',
}
: {}),
header: Icon
? ({ column }) => (
<DataGridColumnHeader
column={column}
title={title}
icon={<Icon />}
/>
)
: () => c.header,
cell: ({ row }) => c.cell(row.original, row.index),
enableSorting: sortable,
enableHiding: c.enableHiding ?? true,
meta: {
headerTitle: title || undefined,
cellClassName: c.className,
headerClassName: c.headerClassName,
},
}
})
}
/** @deprecated Используйте columnDefFromDataGrid */
export const columnDefFromDataTable = columnDefFromDataGrid
/** re-export flexRender для удобства использования в колонках. */
export { flexRender }
+26
View File
@@ -0,0 +1,26 @@
export { applyFiltersToData, getActiveFilters, renderSingleSelectedLabel } from './filter-utils'
export { ResourcePage, type ResourcePageProps, type ResourcePageTab } from './resource-page'
export {
KpiStatGrid,
KpiStatCard,
KpiStatCardTile,
kpiStatItemKey,
type KpiStatItem,
type KpiStatCardData,
type KpiStatCard as KpiStatCardType,
type KpiStatVariant,
type OpsKpiCard,
} from './kpi-stat-grid'
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export {
FrameDataGrid,
columnDefFromDataGrid,
loadStoredColumnVisibility,
dataGridColumnVisibilityOptions,
type FrameDataGridProps,
type DataGridColumnVisibilityOption,
} from './frame-data-grid'
export { OpsDashboard } from './ops-dashboard'
export { DetailPanel, type DetailMetricCard } from './detail-panel'
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
export { SettingsCard } from './settings-card'
@@ -0,0 +1,10 @@
/** Shared grid column classes for hybrid KPI / Quick Actions tiles. */
export function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3'
if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
}
@@ -0,0 +1,325 @@
import type { KeyboardEvent, ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { cn } from '@telemt/ui/lib/utils'
import { kpiCols } from './kpi-cols'
import { Item, ItemMedia } from '@telemt/ui/components/item'
import { Skeleton } from '@telemt/ui/components/skeleton'
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
/**
* KPI tile data — horizontal compact hybrid (icon left + label/Badge + value).
* @see https://reui.io/preview/base/stats-12
*/
export type KpiStatItem = {
id?: string
label: ReactNode
value: ReactNode
hint?: ReactNode
to?: string
search?: Record<string, unknown>
onSelect?: () => void
onClick?: () => void
selected?: boolean
active?: boolean
icon?: ReactNode
iconClassName?: string
variant?: KpiStatVariant
footer?: ReactNode
}
/** CFDM-compatible card shape (id required). */
export type KpiStatCardData = KpiStatItem & { id: string }
/** @deprecated Use KpiStatCardData */
export type OpsKpiCard = KpiStatCardData
/** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */
export type KpiStatCard = KpiStatCardData
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
default: 'text-foreground',
warning: 'text-warning',
destructive: 'text-destructive',
}
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onActivate()
}
}
function resolveActivate(item: KpiStatItem): (() => void) | undefined {
return item.onClick ?? item.onSelect
}
function isSelected(item: KpiStatItem): boolean {
return Boolean(item.selected ?? item.active)
}
function resolveFooter(item: KpiStatItem): ReactNode {
if (item.footer) return item.footer
if (typeof item.hint === 'string') {
return (
<Badge variant="outline" size="sm">
{item.hint}
</Badge>
)
}
if (item.hint) return item.hint
return null
}
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const footer = resolveFooter(item)
const valueVariant = item.variant ?? 'default'
return (
<div className="relative z-10 flex h-full items-start gap-3">
{item.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
item.iconClassName ?? DEFAULT_ICON_CLASS,
)}
>
<ItemMedia variant="icon" className="size-auto">
{item.icon}
</ItemMedia>
</Item>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
{footer ? <div className="shrink-0">{footer}</div> : null}
</div>
<div
className={cn(
'text-2xl leading-none font-bold tabular-nums',
VALUE_VARIANT_CLASS[valueVariant],
)}
>
{item.value}
</div>
</div>
</div>
)
}
function panelClassName(item: KpiStatItem, className?: string) {
const onActivate = resolveActivate(item)
const clickable = Boolean(item.to || onActivate)
const selected = isSelected(item)
return cn(
'relative isolate flex h-full flex-col',
clickable &&
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
selected && 'ring-primary/30 bg-muted/30 ring-1',
className,
)
}
/** Single KPI tile — used for embedded / standalone contexts. */
export function KpiStatCardTile({
item,
embedded = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
className?: string
}) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item, className)
let panel: ReactNode
if (item.to) {
panel = (
<FramePanel className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
} else if (onActivate) {
panel = (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
} else {
panel = (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
if (embedded) {
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
}
return <Frame className="h-full">{panel}</Frame>
}
/** @deprecated Prefer KpiStatCardTile — kept for existing imports */
export function KpiStatCard({
item,
embedded = false,
className,
}: {
item: KpiStatItem
embedded?: boolean
className?: string
}) {
return <KpiStatCardTile item={item} embedded={embedded} className={className} />
}
function KpiStatGridSkeleton({ count }: { count: number }) {
return (
<Frame className="@container w-full">
<div className={cn('grid gap-2', kpiCols(count))}>
{Array.from({ length: count }).map((_, index) => (
<FramePanel key={index} className="flex items-start gap-3">
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-4 w-20" />
<Skeleton className="h-4.5 w-14 rounded-full" />
</div>
<Skeleton className="h-7 w-16" />
</div>
</FramePanel>
))}
</div>
</Frame>
)
}
interface KpiStatGridProps {
/** EvoBGP primary API */
items?: KpiStatItem[]
/** CFDM-compatible API */
cards?: KpiStatCardData[]
isLoading?: boolean
emptyMessage?: ReactNode
emptyIcon?: ReactNode
className?: string
skeletonCount?: number
/** Wrap each tile in its own Frame (analytics panels). */
embedded?: boolean
'aria-label'?: string
}
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
const onActivate = resolveActivate(item)
const panelClass = panelClassName(item)
if (item.to) {
return (
<FramePanel className={panelClass}>
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
<KpiStatCardBody item={item} />
</Link>
</FramePanel>
)
}
if (onActivate) {
return (
<FramePanel
className={panelClass}
onClick={onActivate}
role="button"
tabIndex={0}
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
return (
<FramePanel className={panelClass}>
<KpiStatCardBody item={item} />
</FramePanel>
)
}
/**
* Hybrid KPI — EvoBGP visual + horizontal compact layout (icon left).
* Preview: https://reui.io/preview/base/stats-12
*/
export function KpiStatGrid({
items,
cards,
isLoading = false,
emptyMessage,
emptyIcon,
className,
skeletonCount = 4,
embedded = false,
'aria-label': ariaLabel,
}: KpiStatGridProps) {
if (isLoading) {
return <KpiStatGridSkeleton count={skeletonCount} />
}
const list = items ?? cards ?? []
if (list.length === 0 && (emptyMessage || emptyIcon)) {
return (
<Frame dense spacing="sm" className={cn('w-full', className)}>
<FramePanel className="flex items-center gap-3 p-4">
{emptyIcon}
{emptyMessage ? (
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
) : null}
</FramePanel>
</Frame>
)
}
if (embedded) {
return (
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
))}
</div>
</section>
)
}
return (
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
{list.map((item, index) => (
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
))}
</div>
</Frame>
)
}
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
if (item.id) return item.id
if (typeof item.label === 'string') return item.label
return `kpi-${index}`
}
@@ -0,0 +1,81 @@
import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Skeleton } from '@telemt/ui/components/skeleton'
import { PageHeader } from '@/components/page-header'
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
export type OpsKpiCard = KpiStatCard
interface OpsDashboardProps {
title?: string
description?: string
kpiCards: OpsKpiCard[]
charts: ReactNode
queue: ReactNode
isLoading?: boolean
}
const rootClassName =
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
function OpsDashboardSkeleton() {
return (
<div className={rootClassName}>
<header className="px-1">
<Skeleton className="h-7 w-56" />
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
</header>
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
<div className="grid w-full gap-2 lg:grid-cols-2">
<Skeleton className="h-64 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" />
</div>
<Skeleton className="h-48 w-full rounded-xl" />
</div>
)
}
export function OpsDashboard({
title = 'Панель управления',
description = 'Обзор VPS, платежей, тарифов и синхронизации',
kpiCards,
charts,
queue,
isLoading = false,
}: OpsDashboardProps) {
if (isLoading) {
return <OpsDashboardSkeleton />
}
return (
<div className={rootClassName}>
<PageHeader title={title} description={description} />
<section aria-label="Ключевые метрики">
<KpiStatGrid cards={kpiCards} skeletonCount={4} />
</section>
<section aria-label="Аналитика" className="grid w-full min-w-0 gap-2">
{charts}
</section>
<section aria-label="Требуют внимания">
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Требуют внимания</FrameTitle>
<FrameDescription>
Проблемы оплаты, истекающие VPS и ошибки синхронизации
</FrameDescription>
</FrameHeader>
<FramePanel>{queue}</FramePanel>
</Frame>
</section>
</div>
)
}
@@ -0,0 +1,104 @@
import type { ReactNode } from 'react'
import { Link } from '@tanstack/react-router'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { Item, ItemMedia } from '@telemt/ui/components/item'
import { cn } from '@telemt/ui/lib/utils'
import { kpiCols } from './kpi-cols'
export interface QuickActionItem {
id: string
title: string
description: string
to: string
search?: Record<string, unknown>
icon?: ReactNode
iconClassName?: string
}
interface QuickActionGridProps {
actions: QuickActionItem[]
title?: string
description?: string
className?: string
}
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
function QuickActionBody({ action }: { action: QuickActionItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{action.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
action.iconClassName ?? DEFAULT_ICON_CLASS,
)}
>
<ItemMedia variant="icon" className="size-auto">
{action.icon}
</ItemMedia>
</Item>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<span className="text-foreground text-sm font-medium">{action.title}</span>
<Badge variant="outline" size="sm" className="shrink-0">
Перейти
</Badge>
</div>
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
{action.description}
</p>
</div>
</div>
)
}
/**
* KPI-like quick actions strip (horizontal Frame tiles).
* Preview: https://reui.io/preview/base/stats-12
*/
export function QuickActionGrid({
actions,
title = 'Быстрые действия',
description,
className,
}: QuickActionGridProps) {
if (actions.length === 0) return null
return (
<Frame dense spacing="sm" className={cn('@container w-full', className)}>
{(title || description) && (
<FrameHeader>
{title ? <FrameTitle>{title}</FrameTitle> : null}
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
)}
<div className={cn('grid gap-2', kpiCols(actions.length))}>
{actions.map((action) => (
<FramePanel
key={action.id}
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
>
<Link
to={action.to}
search={action.search}
className="focus-visible:outline-none"
aria-label={`${action.title}: ${action.description}`}
>
<QuickActionBody action={action} />
</Link>
</FramePanel>
))}
</div>
</Frame>
)
}
@@ -0,0 +1,522 @@
import { useCallback, useMemo, useState, type ReactNode } from 'react'
import {
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type ColumnDef,
type PaginationState,
type RowSelectionState,
type SortingState,
} from '@tanstack/react-table'
import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { Badge } from '@/components/reui/badge'
import { DataGrid } from '@/components/reui/data-grid/data-grid'
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import {
Filters,
type Filter,
type FilterFieldConfig,
} from '@/components/reui/filters'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Button } from '@telemt/ui/components/button'
import { Separator } from '@telemt/ui/components/separator'
import { Skeleton } from '@telemt/ui/components/skeleton'
import {
Alert,
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { EmptyState } from '@/components/empty-state'
import { applyFiltersToData } from './filter-utils'
import {
FrameDataGrid,
type FrameDataGridProps,
} from './frame-data-grid'
export interface ResourcePageTab {
id: string
label: string
count?: number
}
type SimpleGridPassthrough<T extends object> = Pick<
FrameDataGridProps<T>,
| 'onRowClick'
| 'pagination'
| 'footerContent'
| 'dense'
| 'initialSorting'
| 'virtualization'
| 'height'
| 'onRowSelectionChange'
| 'enableColumnVisibility'
| 'columnVisibility'
| 'onColumnVisibilityChange'
| 'columnVisibilityTrigger'
| 'columnVisibilityStorageKey'
| 'initialColumnVisibility'
| 'className'
>
export interface ResourcePageProps<T extends object> extends SimpleGridPassthrough<T> {
/** Optional when hideHeader / embedded under PageHeader. */
title?: string
description?: string
tabs?: ResourcePageTab[]
activeTab?: string
onTabChange?: (tabId: string) => void
tabFilter?: (item: T, tabId: string) => boolean
/** Empty / omit → simple Frame grid (no ReUI Filters). Preview: data-grid-filtering-2 */
filterFields?: FilterFieldConfig[]
filters?: Filter[]
onFiltersChange?: (filters: Filter[]) => void
onClearFilters?: () => void
getFilterFieldValue?: (item: T, field: string) => unknown
columns: ColumnDef<T, unknown>[]
data: T[]
getRowId: (row: T, index?: number) => string
isLoading?: boolean
isError?: boolean
error?: Error | null
onRetry?: () => void
primaryAction?: ReactNode
emptyState?: { title: string; description?: string; action?: ReactNode }
pageSize?: number
enableRowSelection?: boolean
selectionToolbar?: (ctx: {
selectedIds: string[]
selectedCount: number
clearSelection: () => void
}) => ReactNode
toolbarExtra?: ReactNode
hideHeader?: boolean
pinLastColumn?: boolean
/** FrameDataGrid aliases when used as simple list. */
emptyTitle?: string
emptyDescription?: string
emptyAction?: ReactNode
/** Alias for primaryAction (FrameDataGrid `actions`). */
actions?: ReactNode
}
function ResourcePageSkeleton() {
return (
<Frame dense variant="default" spacing="sm" className="w-full">
<FrameHeader>
<Skeleton className="h-5 w-48" />
<Skeleton className="mt-1 h-4 w-72" />
</FrameHeader>
<FramePanel className="p-0">
<div className="flex flex-col gap-3 p-4">
<Skeleton className="h-9 w-full max-w-md" />
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
</FramePanel>
</Frame>
)
}
function ResourceLoadError({
error,
onRetry,
}: {
error?: Error | null
onRetry?: () => void
}) {
return (
<Alert variant="destructive">
<CircleAlertIcon />
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription className="flex flex-col gap-2">
<span>{error?.message ?? 'Не удалось загрузить данные'}</span>
{onRetry ? (
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
Повторить
</Button>
) : null}
</AlertDescription>
</Alert>
)
}
const noopFiltersChange = (_filters: Filter[]) => {}
const defaultGetFilterFieldValue = (_item: unknown, _field: string) => undefined
function ResourcePageSimple<T extends object>({
title = '',
description,
columns,
data,
getRowId,
isLoading = false,
isError = false,
error = null,
onRetry,
primaryAction,
actions,
emptyState,
emptyTitle,
emptyDescription,
emptyAction,
pageSize = 10,
enableRowSelection = false,
toolbarExtra,
hideHeader = false,
pinLastColumn = false,
onRowClick,
pagination,
footerContent,
dense,
initialSorting,
virtualization,
height,
onRowSelectionChange,
enableColumnVisibility,
columnVisibility,
onColumnVisibilityChange,
columnVisibilityTrigger,
columnVisibilityStorageKey,
initialColumnVisibility,
className,
}: ResourcePageProps<T>) {
if (isLoading) return <ResourcePageSkeleton />
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
const headerActions = primaryAction ?? actions
const resolvedEmpty = emptyState ?? (
emptyTitle || emptyDescription || emptyAction
? { title: emptyTitle ?? 'Нет записей', description: emptyDescription, action: emptyAction }
: undefined
)
return (
<div className="w-full">
{toolbarExtra ? (
<div className="mb-3 flex flex-wrap items-center gap-2">{toolbarExtra}</div>
) : null}
<FrameDataGrid
title={hideHeader ? undefined : title || undefined}
description={hideHeader ? undefined : description}
actions={hideHeader ? undefined : headerActions}
columns={columns}
data={data}
rowId={getRowId}
emptyTitle={resolvedEmpty?.title}
emptyDescription={resolvedEmpty?.description}
emptyAction={resolvedEmpty?.action}
pinLastColumn={pinLastColumn}
pageSize={pageSize}
enableRowSelection={enableRowSelection}
onRowClick={onRowClick}
pagination={pagination}
footerContent={footerContent}
dense={dense}
initialSorting={initialSorting}
virtualization={virtualization}
height={height}
onRowSelectionChange={onRowSelectionChange}
enableColumnVisibility={enableColumnVisibility}
columnVisibility={columnVisibility}
onColumnVisibilityChange={onColumnVisibilityChange}
columnVisibilityTrigger={columnVisibilityTrigger}
columnVisibilityStorageKey={columnVisibilityStorageKey}
initialColumnVisibility={initialColumnVisibility}
className={className}
/>
</div>
)
}
function ResourcePageFiltered<T extends object>({
title = '',
description,
tabs,
activeTab: controlledTab,
onTabChange,
tabFilter,
filterFields = [],
filters = [],
onFiltersChange = noopFiltersChange,
onClearFilters,
getFilterFieldValue = defaultGetFilterFieldValue as (item: T, field: string) => unknown,
columns,
data,
getRowId,
isLoading = false,
isError = false,
error = null,
onRetry,
primaryAction,
actions,
emptyState,
pageSize = 10,
enableRowSelection = false,
selectionToolbar,
toolbarExtra,
hideHeader = false,
}: ResourcePageProps<T>) {
const headerActions = primaryAction ?? actions
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
const activeTab = controlledTab ?? internalTab
const showFilters = filterFields.length > 0
const [sorting, setSorting] = useState<SortingState>([])
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize,
})
const resetPagination = useCallback(() => {
setPagination((current) =>
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 },
)
}, [])
const filteredData = useMemo(() => {
let result = showFilters
? applyFiltersToData(data, filters, getFilterFieldValue)
: data
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
result = result.filter((item) => tabFilter(item, activeTab))
}
return result
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab, showFilters])
const tabCounts = useMemo(() => {
if (!tabs?.length || !tabFilter) return {}
const base = showFilters
? applyFiltersToData(data, filters, getFilterFieldValue)
: data
const counts: Record<string, number> = {}
for (const tab of tabs) {
counts[tab.id] =
tab.id === 'all'
? base.length
: base.filter((item) => tabFilter(item, tab.id)).length
}
return counts
}, [tabs, tabFilter, data, filters, getFilterFieldValue, showFilters])
const selectedIds = useMemo(
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
[rowSelection],
)
const selectedCount = selectedIds.length
const clearSelection = useCallback(() => {
setRowSelection({})
}, [])
const table = useReactTable({
data: filteredData,
columns,
getRowId: (row) => getRowId(row),
state: { sorting, rowSelection, pagination },
enableRowSelection,
onSortingChange: setSorting,
onRowSelectionChange: setRowSelection,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
const handleTabChange = useCallback(
(value: string) => {
if (onTabChange) onTabChange(value)
else setInternalTab(value)
resetPagination()
},
[onTabChange, resetPagination],
)
const handleFiltersChange = useCallback(
(next: Filter[]) => {
onFiltersChange(next)
resetPagination()
},
[onFiltersChange, resetPagination],
)
const handleClear = useCallback(() => {
onClearFilters?.()
resetPagination()
}, [onClearFilters, resetPagination])
const countedTabs = useMemo(
() =>
(tabs ?? []).map((tab) => ({
id: tab.id,
label: tab.label,
count: tabCounts[tab.id] ?? tab.count ?? 0,
})),
[tabs, tabCounts],
)
if (isLoading) return <ResourcePageSkeleton />
if (isError) return <ResourceLoadError error={error} onRetry={onRetry} />
if (data.length === 0 && emptyState) {
return (
<Frame dense spacing="sm" className="w-full">
<FramePanel className="flex min-h-[min(28rem,55svh)] w-full flex-col items-center justify-center p-0">
<EmptyState
title={emptyState.title}
description={emptyState.description}
action={emptyState.action}
/>
</FramePanel>
</Frame>
)
}
return (
<div className="w-full">
{selectionToolbar && selectedCount > 0
? selectionToolbar({
selectedIds,
selectedCount,
clearSelection,
})
: null}
<DataGrid
table={table}
recordCount={filteredData.length}
emptyMessage="Нет записей по выбранным фильтрам."
tableLayout={{ dense: true }}
>
<Frame dense variant="default" spacing="sm" className="w-full">
{!hideHeader ? (
<FrameHeader className="flex-row items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-px">
<FrameTitle className="text-balance">{title}</FrameTitle>
{description ? (
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
<span>{description}</span>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span className="tabular-nums">
{filteredData.length} записей
</span>
{selectedCount > 0 ? (
<>
<span
className="bg-input size-1 shrink-0 rounded-full"
aria-hidden="true"
/>
<span>{selectedCount} выбрано</span>
</>
) : null}
</FrameDescription>
) : null}
</div>
{headerActions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{headerActions}
</div>
) : null}
</FrameHeader>
) : null}
<FramePanel className="p-0 shadow-none!">
{countedTabs.length > 0 ? (
<>
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
<CountedLineTabs
tabs={countedTabs}
value={activeTab}
onValueChange={handleTabChange}
/>
</div>
<Separator />
</>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
{showFilters ? (
<Filters
filters={filters}
fields={filterFields}
onChange={handleFiltersChange}
size="default"
trigger={
<Button type="button" variant="outline" aria-label="Фильтры">
<FilterIcon className="size-4" aria-hidden="true" />
Фильтры
</Button>
}
/>
) : (
<div />
)}
<div className="flex flex-wrap items-center justify-end gap-2">
{toolbarExtra}
{selectedCount > 0 ? (
<Badge size="sm" variant="secondary">
{selectedCount} выбрано
</Badge>
) : null}
{showFilters && onClearFilters ? (
<Button type="button" variant="outline" onClick={handleClear}>
<FilterXIcon className="size-4" aria-hidden="true" />
Сбросить
</Button>
) : null}
</div>
</div>
{(showFilters || toolbarExtra || selectedCount > 0) ? <Separator /> : null}
<DataGridScrollArea>
<DataGridTable />
</DataGridScrollArea>
<Separator />
<FrameFooter>
<DataGridPagination
sizes={[5, 10, 20, 50]}
rowsPerPageLabel="Строк на странице"
info="{from} - {to} of {count}"
previousPageLabel="Предыдущая"
nextPageLabel="Следующая"
/>
</FrameFooter>
</FramePanel>
</Frame>
</DataGrid>
</div>
)
}
/**
* Ops list page: Frame + DataGrid (+ optional ReUI Filters / tabs).
* Without filterFields/tabs → simple CRUD grid (FrameDataGrid under the hood).
* Preview: https://reui.io/preview/base/data-grid-filtering-2
*/
export function ResourcePage<T extends object>(props: ResourcePageProps<T>) {
const showFilters = (props.filterFields?.length ?? 0) > 0
const hasTabs = (props.tabs?.length ?? 0) > 0
if (!showFilters && !hasTabs) {
return <ResourcePageSimple {...props} />
}
return <ResourcePageFiltered {...props} />
}
@@ -0,0 +1,60 @@
import { type ReactNode } from 'react'
import { cn } from '@telemt/ui/lib/utils'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
interface SettingsCardProps {
title: string
description?: string
children: ReactNode
footer?: ReactNode
className?: string
contentClassName?: string
footerClassName?: string
headerClassName?: string
}
/** Settings section Frame — preview https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3 */
export function SettingsCard({
title,
description,
children,
footer,
className,
contentClassName,
footerClassName,
headerClassName,
}: SettingsCardProps) {
return (
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
<FramePanel className="flex flex-col gap-0 p-0">
<FrameHeader
className={cn('gap-0.5 border-b border-border/50 px-5 py-3', headerClassName)}
>
<FrameTitle>{title}</FrameTitle>
{description ? <FrameDescription>{description}</FrameDescription> : null}
</FrameHeader>
<div className={cn('min-w-0', contentClassName)}>{children}</div>
{footer ? (
<FrameFooter
className={cn(
'justify-end gap-2 border-t border-border/50 px-5 py-3',
footerClassName,
)}
>
{footer}
</FrameFooter>
) : null}
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,124 @@
import type { ReactNode } from 'react'
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
import {
BellIcon,
PlugIcon,
RefreshCwIcon,
SettingsIcon,
} from 'lucide-react'
import { useIsMobile } from '@telemt/ui/hooks/use-mobile'
import { cn } from '@telemt/ui/lib/utils'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { Separator } from '@telemt/ui/components/separator'
export interface SettingsTabConfig {
id: string
to: string
label: string
icon?: ReactNode
exact?: boolean
}
const DEFAULT_TABS: SettingsTabConfig[] = [
{
id: 'general',
to: '/settings',
label: 'Общие',
exact: true,
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
},
{
id: 'sync',
to: '/settings/sync',
label: 'Синхронизация',
icon: <RefreshCwIcon className="size-4" aria-hidden="true" />,
},
{
id: 'notifications',
to: '/settings/notifications',
label: 'Уведомления',
icon: <BellIcon className="size-4" aria-hidden="true" />,
},
{
id: 'integrations',
to: '/settings/integrations',
label: 'Интеграции',
icon: <PlugIcon className="size-4" aria-hidden="true" />,
},
]
interface SettingsShellProps {
title?: string
description?: string
tabs?: SettingsTabConfig[]
}
/** Settings shell — preview https://reui.io/preview/base/settings-16 */
export function SettingsShell({
title = 'Настройки',
description = 'Параметры приложения и связи с другими сервисами',
tabs = DEFAULT_TABS,
}: SettingsShellProps) {
const isMobile = useIsMobile()
const pathname = useRouterState({ select: (s) => s.location.pathname })
return (
<PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
<div className="flex flex-col gap-3">
<PageHeader title={title} description={description} />
<Separator />
</div>
<div
className={cn(
'flex gap-5',
isMobile ? 'flex-col' : 'flex-row items-start',
)}
>
{tabs.length > 1 ? (
<nav
aria-label="Разделы настроек"
className={cn(
'flex gap-1',
isMobile
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
: 'w-44 shrink-0 flex-col',
)}
>
{tabs.map((tab) => {
const isActive = tab.exact
? pathname === tab.to || pathname === `${tab.to}/`
: pathname.startsWith(tab.to)
return (
<Link
key={tab.id}
to={tab.to}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
isMobile && 'shrink-0',
!isMobile && 'w-full',
isActive
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)}
>
{tab.icon}
{tab.label}
</Link>
)
})}
</nav>
) : null}
<div className="min-w-0 flex-1">
<Outlet />
</div>
</div>
</div>
</PageShell>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@telemt/ui/lib/utils"
const alertVariants = cva(
[
"relative w-full text-sm border has-[>svg]:grid-cols-[calc(var(--spacing)*3)_1fr] grid-cols-[0_1fr] grid gap-y-0.5 items-center [&>svg:not([class*=size-])]:size-4",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_[data-slot=alert-action]]:sm:row-end-3",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:items-start",
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_svg]:translate-y-0.5",
"rounded-lg",
"px-3",
"py-2.5",
"has-[>svg]:gap-x-2.5",
],
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"border-destructive/30 bg-destructive/4 [&>svg]:text-destructive",
info: "border-info/30 bg-info/4 [&>svg]:text-info",
success: "border-success/30 bg-success/4 [&>svg]:text-success",
warning: "border-warning/30 bg-warning/4 [&>svg]:text-warning",
invert:
"border-invert bg-invert text-invert-foreground [&_[data-slot=alert-description]]:text-invert-foreground/70",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-action"
className={cn(
"flex gap-1.5 max-sm:col-start-2 max-sm:mt-2 max-sm:justify-start sm:col-start-3 sm:row-start-1 sm:justify-end sm:self-center",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription, AlertAction }
@@ -0,0 +1,343 @@
"use client"
import { Autocomplete as AutocompletePrimitive } from "@base-ui/react/autocomplete"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@telemt/ui/lib/utils"
import { ScrollArea } from "@telemt/ui/components/scroll-area"
import { XIcon, ChevronsUpDownIcon } from "lucide-react"
const inputVariants = cva(
"outline-none flex w-full text-foreground placeholder:text-muted-foreground disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 [[readonly]]:bg-muted/80 [[readonly]]:cursor-not-allowed border border-input focus-visible:border-ring aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 rounded-lg bg-transparent dark:bg-input/30 text-sm transition-colors focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-3",
{
variants: {
size: {
sm: "h-7 px-2 [&~[data-slot=autocomplete-clear]]:end-1.5 [&~[data-slot=autocomplete-trigger]]:end-1.5",
default:
"h-8 px-2.5 [&~[data-slot=autocomplete-clear]]:end-1.75 [&~[data-slot=autocomplete-trigger]]:end-1.75",
lg: "h-9 px-2.5 [&~[data-slot=autocomplete-clear]]:end-2 [&~[data-slot=autocomplete-trigger]]:end-2",
},
},
defaultVariants: {
size: "default",
},
}
)
const Autocomplete = AutocompletePrimitive.Root
function AutocompleteValue({ ...props }: AutocompletePrimitive.Value.Props) {
return (
<AutocompletePrimitive.Value data-slot="autocomplete-value" {...props} />
)
}
function AutocompleteInput({
className,
size = "default",
showClear = false,
showTrigger = false,
...props
}: Omit<AutocompletePrimitive.Input.Props, "size"> &
VariantProps<typeof inputVariants> & {
showClear?: boolean
showTrigger?: boolean
}) {
return (
<div className="relative w-full">
<AutocompletePrimitive.Input
data-slot="autocomplete-input"
data-size={size}
className={cn(inputVariants({ size }), className)}
{...props}
/>
{showTrigger && <AutocompleteTrigger />}
{showClear && <AutocompleteClear />}
</div>
)
}
function AutocompleteStatus({
className,
...props
}: AutocompletePrimitive.Status.Props) {
return (
<AutocompletePrimitive.Status
data-slot="autocomplete-status"
className={cn(
"text-muted-foreground px-2 py-1.5 text-sm empty:m-0 empty:p-0",
className
)}
{...props}
/>
)
}
function AutocompletePortal({ ...props }: AutocompletePrimitive.Portal.Props) {
return (
<AutocompletePrimitive.Portal data-slot="autocomplete-portal" {...props} />
)
}
function AutocompleteBackdrop({
...props
}: AutocompletePrimitive.Backdrop.Props) {
return (
<AutocompletePrimitive.Backdrop
data-slot="autocomplete-backdrop"
{...props}
/>
)
}
function AutocompletePositioner({
className,
...props
}: AutocompletePrimitive.Positioner.Props) {
return (
<AutocompletePrimitive.Positioner
data-slot="autocomplete-positioner"
className={cn("z-50 outline-none", className)}
{...props}
/>
)
}
function AutocompleteList({
className,
scrollAreaClassName,
...props
}: AutocompletePrimitive.List.Props & {
scrollAreaClassName?: string
scrollFade?: boolean
scrollbarGutter?: boolean
}) {
return (
<ScrollArea
className={cn(
"size-full min-h-0 **:data-[slot=scroll-area-viewport]:h-full **:data-[slot=scroll-area-viewport]:overscroll-contain",
scrollAreaClassName
)}
>
<AutocompletePrimitive.List
data-slot="autocomplete-list"
className={cn(
"not-empty:px-1 not-empty:py-1 not-empty:scroll-py-1 in-data-has-overflow-y:me-3",
className
)}
{...props}
/>
</ScrollArea>
)
}
function AutocompleteCollection({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Collection>) {
return (
<AutocompletePrimitive.Collection
data-slot="autocomplete-collection"
{...props}
/>
)
}
function AutocompleteRow({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Row>) {
return (
<AutocompletePrimitive.Row
data-slot="autocomplete-row"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function AutocompleteItem({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Item>) {
return (
<AutocompletePrimitive.Item
data-slot="autocomplete-item"
className={cn(
"text-foreground data-highlighted:text-foreground data-highlighted:before:bg-accent gap-1.5 rounded-md px-1.5 py-1 text-sm data-highlighted:before:rounded-sm [&_svg:not([class*='size-'])]:size-4 relative flex cursor-default items-center outline-hidden transition-colors select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-highlighted:relative data-highlighted:z-0 data-highlighted:before:absolute data-highlighted:before:inset-x-0 data-highlighted:before:inset-y-0 data-highlighted:before:z-[-1] [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([role=img]):not([class*=text-])]:opacity-60",
className
)}
{...props}
/>
)
}
export interface AutocompleteContentProps extends React.ComponentProps<
typeof AutocompletePrimitive.Popup
> {
align?: AutocompletePrimitive.Positioner.Props["align"]
sideOffset?: AutocompletePrimitive.Positioner.Props["sideOffset"]
alignOffset?: AutocompletePrimitive.Positioner.Props["alignOffset"]
side?: AutocompletePrimitive.Positioner.Props["side"]
anchor?: AutocompletePrimitive.Positioner.Props["anchor"]
showBackdrop?: boolean
}
function AutocompleteContent({
className,
children,
showBackdrop = false,
align = "start",
sideOffset = 4,
alignOffset = 0,
side = "bottom",
anchor,
...props
}: AutocompleteContentProps) {
return (
<AutocompletePortal>
{showBackdrop && <AutocompleteBackdrop />}
<AutocompletePositioner
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
side={side}
anchor={anchor}
>
<div className="relative flex max-h-full">
<AutocompletePrimitive.Popup
data-slot="autocomplete-popup"
className={cn(
"bg-popover text-popover-foreground rounded-lg shadow-md ring-foreground/10 flex max-h-[min(var(--available-height),24rem)] w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) scroll-pt-2 scroll-pb-2 flex-col overscroll-contain py-0.5 ring-1 transition-[scale,opacity] has-data-starting-style:scale-98 has-data-starting-style:opacity-0 has-data-[side=none]:scale-100 has-data-[side=none]:transition-none",
className
)}
{...props}
>
{children}
</AutocompletePrimitive.Popup>
</div>
</AutocompletePositioner>
</AutocompletePortal>
)
}
function AutocompleteGroup({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Group>) {
return (
<AutocompletePrimitive.Group data-slot="autocomplete-group" {...props} />
)
}
function AutocompleteGroupLabel({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.GroupLabel>) {
return (
<AutocompletePrimitive.GroupLabel
data-slot="autocomplete-group-label"
className={cn(
"text-muted-foreground px-1.5 py-1 text-xs font-medium",
className
)}
{...props}
/>
)
}
function AutocompleteEmpty({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Empty>) {
return (
<AutocompletePrimitive.Empty
data-slot="autocomplete-empty"
className={cn(
"text-muted-foreground px-2 py-1.5 text-sm text-center empty:m-0 empty:p-0",
className
)}
{...props}
/>
)
}
function AutocompleteClear({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Clear>) {
return (
<AutocompletePrimitive.Clear
data-slot="autocomplete-clear"
className={cn(
"ring-offset-background focus:ring-ring absolute top-1/2 -translate-y-1/2 cursor-pointer opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-disabled:pointer-events-none",
className
)}
{...props}
>
<XIcon className="size-4" />
</AutocompletePrimitive.Clear>
)
}
function AutocompleteTrigger({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Trigger>) {
return (
<AutocompletePrimitive.Trigger
data-slot="autocomplete-trigger"
className={cn(
"focus:ring-ring ring-offset-background absolute top-1/2 -translate-y-1/2 cursor-pointer focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none has-[+[data-slot=autocomplete-clear]]:hidden data-disabled:pointer-events-none",
className
)}
{...props}
>
<ChevronsUpDownIcon className="size-4 opacity-70" />
</AutocompletePrimitive.Trigger>
)
}
function AutocompleteArrow({
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Arrow>) {
return (
<AutocompletePrimitive.Arrow data-slot="autocomplete-arrow" {...props} />
)
}
function AutocompleteSeparator({
className,
...props
}: React.ComponentProps<typeof AutocompletePrimitive.Separator>) {
return (
<AutocompletePrimitive.Separator
data-slot="autocomplete-separator"
className={cn(
"bg-border my-1.5 h-px",
className
)}
{...props}
/>
)
}
export {
Autocomplete,
AutocompleteValue,
AutocompleteTrigger,
AutocompleteInput,
AutocompleteStatus,
AutocompletePortal,
AutocompleteBackdrop,
AutocompletePositioner,
AutocompleteContent,
AutocompleteList,
AutocompleteCollection,
AutocompleteRow,
AutocompleteItem,
AutocompleteGroup,
AutocompleteGroupLabel,
AutocompleteEmpty,
AutocompleteClear,
AutocompleteArrow,
AutocompleteSeparator,
}
+102
View File
@@ -0,0 +1,102 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@telemt/ui/lib/utils"
const badgeVariants = cva(
[
"relative inline-flex shrink-0 items-center justify-center w-fit border border-transparent font-medium whitespace-nowrap outline-none transition-shadow",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-3",
],
{
variants: {
variant: {
default: "bg-primary text-primary-foreground",
outline: "border-border bg-transparent dark:bg-input/32",
secondary: "bg-secondary text-secondary-foreground",
info: "bg-info text-white",
success: "bg-success text-white",
warning: "bg-warning text-white",
destructive: "bg-destructive text-white",
focus: "bg-focus text-focus-foreground",
invert: "bg-invert text-invert-foreground",
"primary-light":
"border-primary/10 bg-primary/10 text-primary dark:border-primary/25 dark:bg-primary/15 dark:text-primary",
"warning-light":
"border-warning/15 bg-warning/10 text-warning-foreground dark:border-warning/25 dark:bg-warning/15 dark:text-warning",
"success-light":
"border-success/15 bg-success/10 text-success-foreground dark:border-success/25 dark:bg-success/15 dark:text-success",
"info-light":
"border-info/15 bg-info/10 text-info-foreground dark:border-info/25 dark:bg-info/15 dark:text-info",
"destructive-light":
"border-destructive/15 bg-destructive/10 text-destructive-foreground dark:border-destructive/25 dark:bg-destructive/15 dark:text-destructive",
"invert-light":
"border-invert/15 bg-invert/10 text-foreground dark:border-invert/45 dark:bg-invert/35 dark:text-invert-foreground",
"focus-light":
"border-focus/15 bg-focus/10 text-focus-foreground dark:border-focus/25 dark:bg-focus/15 dark:text-focus",
"primary-outline":
"bg-background border-border text-primary dark:bg-input/30",
"warning-outline":
"bg-background border-border text-warning-foreground dark:bg-input/30",
"success-outline":
"bg-background border-border text-success-foreground dark:bg-input/30",
"info-outline":
"bg-background border-border text-info-foreground dark:bg-input/30",
"destructive-outline":
"bg-background border-border text-destructive-foreground dark:bg-input/30",
"invert-outline":
"bg-background border-border text-invert-foreground dark:bg-input/30",
"focus-outline":
"bg-background border-border text-focus-foreground dark:bg-input/30",
},
size: {
xs: "px-1 py-0.25 text-[0.6rem] leading-none h-4 min-w-4 gap-1",
sm: "px-1 py-0.25 text-[0.625rem] leading-none h-4.5 min-w-4.5 gap-1",
default: "px-1.25 py-0.5 text-xs h-5 min-w-5 gap-1",
lg: "px-1.5 py-0.5 text-xs h-5.5 min-w-5.5 gap-1",
xl: "px-2 py-0.75 text-sm h-6 min-w-6 gap-1.5",
},
/** `default`: active style radius. `full`: pill radius. */
radius: {
default:
"rounded-sm",
full: "rounded-full",
},
},
defaultVariants: {
variant: "default",
size: "default",
radius: "default",
},
}
)
interface BadgeProps extends useRender.ComponentProps<"span"> {
variant?: VariantProps<typeof badgeVariants>["variant"]
size?: VariantProps<typeof badgeVariants>["size"]
radius?: VariantProps<typeof badgeVariants>["radius"]
}
function Badge({
className,
variant,
size,
radius,
render,
...props
}: BadgeProps) {
const defaultProps = {
"data-slot": "badge",
className: cn(badgeVariants({ variant, size, radius, className })),
}
return useRender({
defaultTagName: "span",
render,
props: mergeProps<"span">(defaultProps, props),
})
}
export { Badge, badgeVariants, type BadgeProps }
@@ -0,0 +1,220 @@
import { useId, useMemo, useState } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@telemt/ui/lib/utils'
import { Input } from '@telemt/ui/components/input'
import { Popover, PopoverContent, PopoverTrigger } from '@telemt/ui/components/popover'
const PROJECT_COLOR_PRESETS = [
'#3b82f6',
'#6366f1',
'#8b5cf6',
'#ec4899',
'#ef4444',
'#f97316',
'#eab308',
'#22c55e',
'#14b8a6',
'#06b6d4',
'#64748b',
'#18181b',
] as const
const colorPickerGroupVariants = cva(
'relative flex w-full items-stretch overflow-hidden rounded-lg border border-input bg-transparent transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',
{
variants: {
size: {
sm: 'h-7 text-sm',
default: 'h-8 text-sm',
lg: 'h-9 text-sm',
},
},
defaultVariants: {
size: 'default',
},
},
)
const colorPickerSwatchVariants = cva(
'flex shrink-0 cursor-pointer items-center justify-center border-e border-input transition-colors hover:bg-accent disabled:pointer-events-none disabled:opacity-50',
{
variants: {
size: {
sm: 'w-9',
default: 'w-10',
lg: 'w-11',
},
},
defaultVariants: {
size: 'default',
},
},
)
const colorPickerInputVariants = cva(
'min-w-0 flex-1 border-0 bg-transparent shadow-none focus-visible:border-0 focus-visible:ring-0 dark:bg-transparent',
{
variants: {
size: {
sm: 'h-7 rounded-e-lg px-2',
default: 'h-8 rounded-e-lg px-2.5',
lg: 'h-9 rounded-e-lg px-2.5',
},
},
defaultVariants: {
size: 'default',
},
},
)
function isValidHex(value: string): boolean {
return /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(value.trim())
}
function normalizeHex(value: string): string {
const trimmed = value.trim()
if (!trimmed) return ''
const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}`
if (/^#[0-9A-Fa-f]{3}$/.test(withHash)) {
const [, r, g, b] = withHash
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase()
}
if (/^#[0-9A-Fa-f]{6}$/.test(withHash)) {
return withHash.toLowerCase()
}
return withHash
}
function toPickerValue(value: string): string {
const normalized = normalizeHex(value)
return isValidHex(normalized) ? normalized : '#3b82f6'
}
interface ColorPickerProps extends VariantProps<typeof colorPickerGroupVariants> {
id?: string
value?: string
onChange?: (value: string) => void
onBlur?: () => void
disabled?: boolean
placeholder?: string
'aria-invalid'?: boolean
className?: string
}
function ColorPicker({
id,
value = '',
onChange,
onBlur,
disabled,
placeholder = '#3b82f6',
size = 'default',
className,
'aria-invalid': ariaInvalid,
}: ColorPickerProps) {
const generatedId = useId()
const inputId = id ?? generatedId
const [open, setOpen] = useState(false)
const sizeValue = size ?? 'default'
const displayValue = value ?? ''
const swatchColor = useMemo(() => {
const normalized = normalizeHex(displayValue)
return isValidHex(normalized) ? normalized : undefined
}, [displayValue])
const handleHexChange = (next: string) => {
onChange?.(next)
}
const handleHexBlur = () => {
const normalized = normalizeHex(displayValue)
if (normalized !== displayValue) {
onChange?.(normalized)
}
onBlur?.()
}
const applyColor = (next: string) => {
onChange?.(normalizeHex(next))
}
return (
<div
className={cn(colorPickerGroupVariants({ size: sizeValue }), className)}
data-slot="color-picker"
data-size={sizeValue}
aria-invalid={ariaInvalid}
>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
disabled={disabled}
render={
<button
type="button"
className={cn(colorPickerSwatchVariants({ size: sizeValue }))}
aria-label="Выбрать цвет"
disabled={disabled}
>
<span
className={cn(
'size-4 rounded-sm ring-1 ring-foreground/10',
!swatchColor && 'bg-muted',
)}
style={swatchColor ? { backgroundColor: swatchColor } : undefined}
/>
</button>
}
/>
<PopoverContent align="start" className="w-64 gap-3 p-3">
<label className="flex flex-col gap-1.5 text-xs text-muted-foreground">
Палитра
<input
type="color"
value={toPickerValue(displayValue)}
disabled={disabled}
className="h-28 w-full cursor-pointer rounded-md border border-input bg-transparent p-1"
onChange={(e) => applyColor(e.target.value)}
/>
</label>
<div className="flex flex-col gap-1.5">
<span className="text-xs text-muted-foreground">Быстрый выбор</span>
<div className="grid grid-cols-6 gap-1.5">
{PROJECT_COLOR_PRESETS.map((preset) => (
<button
key={preset}
type="button"
aria-label={preset}
disabled={disabled}
className={cn(
'aspect-square rounded-md ring-1 ring-foreground/10 transition-transform hover:scale-105',
swatchColor === preset && 'ring-2 ring-ring',
)}
style={{ backgroundColor: preset }}
onClick={() => {
applyColor(preset)
setOpen(false)
}}
/>
))}
</div>
</div>
</PopoverContent>
</Popover>
<Input
id={inputId}
value={displayValue}
disabled={disabled}
placeholder={placeholder}
aria-invalid={ariaInvalid}
className={colorPickerInputVariants({ size: sizeValue })}
style={swatchColor ? { color: swatchColor } : undefined}
onChange={(e) => handleHexChange(e.target.value)}
onBlur={handleHexBlur}
/>
</div>
)
}
export { ColorPicker, normalizeHex, isValidHex, PROJECT_COLOR_PRESETS }
@@ -0,0 +1,165 @@
import { useMemo, useState } from "react"
import { Badge } from "@/components/reui/badge"
import { type Column } from "@tanstack/react-table"
import { cn } from "@telemt/ui/lib/utils"
import { Button } from "@telemt/ui/components/button"
import { Input } from "@telemt/ui/components/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@telemt/ui/components/popover"
import { Separator } from "@telemt/ui/components/separator"
import { CirclePlusIcon, CheckIcon } from "lucide-react"
interface DataGridColumnFilterProps<TData, TValue> {
column?: Column<TData, TValue>
title?: string
options: {
label: string
value: string
icon?: React.ComponentType<{ className?: string }>
}[]
}
function DataGridColumnFilter<TData, TValue>({
column,
title,
options,
}: DataGridColumnFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues()
const selectedValues = new Set(column?.getFilterValue() as string[])
const [searchQuery, setSearchQuery] = useState("")
const filteredOptions = useMemo(() => {
if (!searchQuery) return options
return options.filter((option) =>
option.label.toLowerCase().includes(searchQuery.toLowerCase())
)
}, [options, searchQuery])
return (
<Popover>
<PopoverTrigger
render={
<Button variant="outline" size="sm">
<CirclePlusIcon className="size-4" />
{title}
{selectedValues?.size > 0 && (
<>
<Separator orientation="vertical" className="mx-2 h-4" />
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal lg:hidden"
>
{selectedValues.size}
</Badge>
<div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? (
<Badge
variant="secondary"
className="rounded-sm px-1 font-normal"
>
{selectedValues.size} selected
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge
variant="secondary"
key={option.value}
className="rounded-sm px-1 font-normal"
>
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
}
/>
<PopoverContent className="w-[200px] p-0" align="start">
<div className="p-2">
<Input
placeholder={title}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8"
/>
</div>
<div className="max-h-[300px] overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className="text-muted-foreground py-6 text-center text-sm">
No results found.
</div>
) : (
<div className="p-1">
{filteredOptions.map((option) => {
const isSelected = selectedValues.has(option.value)
return (
<div
key={option.value}
onClick={() => {
if (isSelected) {
selectedValues.delete(option.value)
} else {
selectedValues.add(option.value)
}
const filterValues = Array.from(selectedValues)
column?.setFilterValue(
filterValues.length ? filterValues : undefined
)
}}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none",
"hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
)}
>
<div
className={cn(
"border-primary me-2 flex h-4 w-4 items-center justify-center rounded-sm border",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible"
)}
>
<CheckIcon className="h-4 w-4" />
</div>
{option.icon && (
<option.icon className="text-muted-foreground mr-2 h-4 w-4" />
)}
<span>{option.label}</span>
{facets?.get(option.value) && (
<span className="ms-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
)}
</div>
)
})}
</div>
)}
{selectedValues.size > 0 && (
<>
<div className="bg-border -mx-1 my-1 h-px" />
<div className="p-1">
<div
onClick={() => column?.setFilterValue(undefined)}
className="hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center justify-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none"
>
Clear filters
</div>
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
)
}
export { DataGridColumnFilter, type DataGridColumnFilterProps }
@@ -0,0 +1,345 @@
"use client"
import { type HTMLAttributes, memo, type ReactNode, useMemo } from "react"
import {
getColumnHeaderLabel,
useDataGrid,
} from "@/components/reui/data-grid/data-grid"
import { type Column } from "@tanstack/react-table"
import { cn } from "@telemt/ui/lib/utils"
import { Button } from "@telemt/ui/components/button"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@telemt/ui/components/dropdown-menu"
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon, CheckIcon, ArrowLeftToLineIcon, ArrowRightToLineIcon, ArrowLeftIcon, ArrowRightIcon, Settings2Icon, PinOffIcon } from "lucide-react"
interface DataGridColumnHeaderProps<
TData,
TValue,
> extends HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>
/** When omitted, uses `column.columnDef.meta.headerTitle`, then a string `columnDef.header`, then `column.id`. */
title?: string
icon?: ReactNode
pinnable?: boolean
filter?: ReactNode
visibility?: boolean
}
function DataGridColumnHeaderInner<TData, TValue>({
column,
title,
icon,
className,
filter,
visibility = false,
}: DataGridColumnHeaderProps<TData, TValue>) {
const { isLoading, table, props, recordCount } = useDataGrid()
const resolvedTitle = title ?? getColumnHeaderLabel(column)
const columnOrder = table.getState().columnOrder
const columnVisibilityKey = JSON.stringify(table.getState().columnVisibility)
const isSorted = column.getIsSorted()
const isPinned = column.getIsPinned()
const canSort = column.getCanSort()
const canPin = column.getCanPin()
const canResize = column.getCanResize()
const columnIndex = columnOrder.indexOf(column.id)
const canMoveLeft = columnIndex > 0
const canMoveRight = columnIndex < columnOrder.length - 1
const handleSort = () => {
if (isSorted === "asc") {
column.toggleSorting(true)
} else if (isSorted === "desc") {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}
const headerLabelClassName = cn(
"text-secondary-foreground/80 inline-flex h-full items-center gap-1.5 font-normal [&_svg]:opacity-60 text-[0.8125rem] leading-[calc(1.125/0.8125)] [&_svg]:size-3.5",
className
)
const headerButtonClassName = cn(
"text-secondary-foreground/80 hover:bg-secondary data-[state=open]:bg-secondary hover:text-foreground data-[state=open]:text-foreground -ms-2 px-2 font-normal h-6 rounded-lg",
className
)
const sortIcon =
canSort &&
(isSorted === "desc" ? (
<ArrowDownIcon className="size-3.25" />
) : isSorted === "asc" ? (
<ArrowUpIcon className="size-3.25" />
) : (
<ChevronsUpDownIcon className="mt-px size-3.25" />
))
const hasControls =
props.tableLayout?.columnsMovable ||
(props.tableLayout?.columnsVisibility && visibility) ||
(props.tableLayout?.columnsPinnable && canPin) ||
filter
const menuItems = useMemo(() => {
const items: ReactNode[] = []
let hasPreviousSection = false
// Filter section
if (filter) {
items.push(
<DropdownMenuGroup key="group-filter">
<DropdownMenuLabel key="filter">{filter}</DropdownMenuLabel>
</DropdownMenuGroup>
)
hasPreviousSection = true
}
// Sort section
if (canSort) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-sort" />)
}
items.push(
<DropdownMenuItem
key="sort-asc"
onClick={() => {
if (isSorted === "asc") {
column.clearSorting()
} else {
column.toggleSorting(false)
}
}}
disabled={!canSort}
>
<ArrowUpIcon className="size-3.5!" />
<span className="grow">Asc</span>
{isSorted === "asc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="sort-desc"
onClick={() => {
if (isSorted === "desc") {
column.clearSorting()
} else {
column.toggleSorting(true)
}
}}
disabled={!canSort}
>
<ArrowDownIcon className="size-3.5!" />
<span className="grow">Desc</span>
{isSorted === "desc" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Pin section
if (props.tableLayout?.columnsPinnable && canPin) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-pin" />)
}
items.push(
<DropdownMenuItem
key="pin-left"
onClick={() => column.pin(isPinned === "left" ? false : "left")}
>
<ArrowLeftToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to left</span>
{isPinned === "left" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>,
<DropdownMenuItem
key="pin-right"
onClick={() => column.pin(isPinned === "right" ? false : "right")}
>
<ArrowRightToLineIcon className="size-3.5!" aria-hidden="true" />
<span className="grow">Pin to right</span>
{isPinned === "right" && (
<CheckIcon className="text-primary size-4 opacity-100!" />
)}
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Move section
if (props.tableLayout?.columnsMovable) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-move" />)
}
items.push(
<DropdownMenuItem
key="move-left"
onClick={() => {
if (columnIndex > 0) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex - 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
}}
disabled={!canMoveLeft || isPinned !== false}
>
<ArrowLeftIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Left</span>
</DropdownMenuItem>,
<DropdownMenuItem
key="move-right"
onClick={() => {
if (columnIndex < columnOrder.length - 1) {
const newOrder = [...columnOrder]
const [movedColumn] = newOrder.splice(columnIndex, 1)
newOrder.splice(columnIndex + 1, 0, movedColumn)
table.setColumnOrder(newOrder)
}
}}
disabled={!canMoveRight || isPinned !== false}
>
<ArrowRightIcon className="size-3.5!" aria-hidden="true" />
<span>Move to Right</span>
</DropdownMenuItem>
)
hasPreviousSection = true
}
// Visibility section
if (props.tableLayout?.columnsVisibility && visibility) {
if (hasPreviousSection) {
items.push(<DropdownMenuSeparator key="sep-visibility" />)
}
items.push(
<DropdownMenuSub key="visibility">
<DropdownMenuSubTrigger>
<Settings2Icon className="size-3.5!" />
<span>Columns</span>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent side="right">
{table
.getAllColumns()
.filter((col) => col.getCanHide())
.map((col) => (
<DropdownMenuCheckboxItem
key={col.id}
checked={col.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => col.toggleVisibility(!!value)}
className="capitalize"
>
{getColumnHeaderLabel(col)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
return items
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
filter,
canSort,
isSorted,
column,
props.tableLayout?.columnsPinnable,
props.tableLayout?.columnsMovable,
props.tableLayout?.columnsVisibility,
canPin,
isPinned,
canMoveLeft,
canMoveRight,
visibility,
table,
columnIndex,
columnOrder,
columnVisibilityKey, // Needed to update checkbox states when visibility changes
])
if (hasControls) {
return (
<div className="flex h-full items-center justify-between gap-1.5">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
>
{icon && icon}
{resolvedTitle}
{sortIcon}
</Button>
}
/>
<DropdownMenuContent className="w-40" align="start">
{menuItems}
</DropdownMenuContent>
</DropdownMenu>
{props.tableLayout?.columnsPinnable && canPin && isPinned && (
<Button
size="icon-sm"
variant="ghost"
className="-me-1 size-7 rounded-md"
onClick={() => column.pin(false)}
aria-label={`Unpin ${resolvedTitle} column`}
title={`Unpin ${resolvedTitle} column`}
>
<PinOffIcon className="size-3.5! opacity-50!" aria-hidden="true" />
</Button>
)}
</div>
)
}
if (canSort || (props.tableLayout?.columnsResizable && canResize)) {
return (
<div className="flex h-full items-center">
<Button
variant="ghost"
className={headerButtonClassName}
disabled={isLoading || recordCount === 0}
onClick={handleSort}
>
{icon && icon}
{resolvedTitle}
{sortIcon}
</Button>
</div>
)
}
return (
<div className={headerLabelClassName}>
{icon && icon}
{resolvedTitle}
</div>
)
}
const DataGridColumnHeader = memo(
DataGridColumnHeaderInner
) as typeof DataGridColumnHeaderInner
export { DataGridColumnHeader, type DataGridColumnHeaderProps }
@@ -0,0 +1,51 @@
import { type ReactElement } from "react"
import { getColumnHeaderLabel } from "@/components/reui/data-grid/data-grid"
import { type Table } from "@tanstack/react-table"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@telemt/ui/components/dropdown-menu"
function DataGridColumnVisibility<TData>({
table,
trigger,
}: {
table: Table<TData>
trigger: ReactElement<Record<string, unknown>>
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger render={trigger} />
<DropdownMenuContent align="end" className="min-w-[150px]">
<DropdownMenuGroup>
<DropdownMenuLabel className="font-medium">
Колонки
</DropdownMenuLabel>
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onSelect={(event) => event.preventDefault()}
onCheckedChange={(value) => column.toggleVisibility(!!value)}
>
{getColumnHeaderLabel(column)}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
export { DataGridColumnVisibility }
@@ -0,0 +1,236 @@
"use client"
import React, { type ReactNode } from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { cn } from "@telemt/ui/lib/utils"
import { Button } from "@telemt/ui/components/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@telemt/ui/components/select"
import { Skeleton } from "@telemt/ui/components/skeleton"
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
interface DataGridPaginationProps {
sizes?: number[]
sizesInfo?: string
sizesLabel?: string
sizesDescription?: string
sizesSkeleton?: ReactNode
more?: boolean
moreLimit?: number
info?: string
infoSkeleton?: ReactNode
className?: string
rowsPerPageLabel?: string
previousPageLabel?: string
nextPageLabel?: string
pageLabel?: string
previousPagesLabel?: string
nextPagesLabel?: string
ellipsisText?: string
}
function DataGridPagination(props: DataGridPaginationProps): React.JSX.Element {
const { table, recordCount, isLoading } = useDataGrid()
const defaultProps: Partial<DataGridPaginationProps> = {
sizes: [5, 10, 25, 50, 100],
sizesLabel: "Show",
sizesDescription: "per page",
sizesSkeleton: <Skeleton className="h-8 w-44" />,
moreLimit: 5,
more: false,
info: "{from} - {to} of {count}",
infoSkeleton: <Skeleton className="h-8 w-60" />,
rowsPerPageLabel: "Rows per page",
previousPageLabel: "Go to previous page",
nextPageLabel: "Go to next page",
pageLabel: "Page {page}",
previousPagesLabel: "Previous pages",
nextPagesLabel: "Next pages",
ellipsisText: "...",
}
const mergedProps: DataGridPaginationProps = { ...defaultProps, ...props }
const btnBaseClasses = "size-7 p-0 text-sm"
const btnArrowClasses = btnBaseClasses + " rtl:transform rtl:rotate-180"
const pageIndex = table.getState().pagination.pageIndex
const pageSize = table.getState().pagination.pageSize
const from = pageIndex * pageSize + 1
const to = Math.min((pageIndex + 1) * pageSize, recordCount)
const pageCount = table.getPageCount()
// Replace placeholders in paginationInfo
const paginationInfo = mergedProps?.info
? mergedProps.info
.replace("{from}", from.toString())
.replace("{to}", to.toString())
.replace("{count}", recordCount.toString())
: `${from} - ${to} of ${recordCount}`
// Pagination limit logic
const paginationMoreLimit = mergedProps?.moreLimit || 5
// Determine the start and end of the pagination group
const currentGroupStart =
Math.floor(pageIndex / paginationMoreLimit) * paginationMoreLimit
const currentGroupEnd = Math.min(
currentGroupStart + paginationMoreLimit,
pageCount
)
// Render page buttons based on the current group
const renderPageButtons = () => {
const buttons = []
for (let i = currentGroupStart; i < currentGroupEnd; i++) {
buttons.push(
<Button
key={i}
size="icon-sm"
variant="ghost"
aria-label={mergedProps.pageLabel?.replace("{page}", String(i + 1))}
aria-current={pageIndex === i ? "page" : undefined}
className={cn(btnBaseClasses, "text-muted-foreground", {
"bg-accent text-accent-foreground": pageIndex === i,
})}
onClick={() => {
if (pageIndex !== i) {
table.setPageIndex(i)
}
}}
>
{i + 1}
</Button>
)
}
return buttons
}
// Render a "previous" ellipsis button if there are previous pages to show
const renderEllipsisPrevButton = () => {
if (currentGroupStart > 0) {
return (
<Button
size="icon-sm"
className={btnBaseClasses}
variant="ghost"
aria-label={mergedProps.previousPagesLabel}
onClick={() => table.setPageIndex(currentGroupStart - 1)}
>
{mergedProps.ellipsisText}
</Button>
)
}
return null
}
// Render a "next" ellipsis button if there are more pages to show after the current group
const renderEllipsisNextButton = () => {
if (currentGroupEnd < pageCount) {
return (
<Button
className={btnBaseClasses}
variant="ghost"
size="icon-sm"
aria-label={mergedProps.nextPagesLabel}
onClick={() => table.setPageIndex(currentGroupEnd)}
>
{mergedProps.ellipsisText}
</Button>
)
}
return null
}
return (
<div
data-slot="data-grid-pagination"
className={cn(
"flex grow flex-col flex-wrap items-center justify-between gap-2.5 py-2.5 sm:flex-row sm:py-0",
mergedProps?.className
)}
>
<div className="order-2 flex flex-wrap items-center gap-2.5 pb-2.5 sm:order-1 sm:pb-0">
{isLoading ? (
mergedProps?.sizesSkeleton
) : (
<>
<div className="text-muted-foreground text-sm">
{mergedProps.rowsPerPageLabel}
</div>
<Select
value={`${pageSize}`}
onValueChange={(value) => {
const newPageSize = Number(value)
table.setPageSize(newPageSize)
}}
>
<SelectTrigger className="min-w-18 tabular-nums" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent className="min-w-18">
{mergedProps?.sizes?.map((size: number) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</>
)}
</div>
<div className="order-1 flex flex-col items-center justify-center gap-2.5 pt-2.5 sm:order-2 sm:flex-row sm:justify-end sm:pt-0">
{isLoading ? (
mergedProps?.infoSkeleton
) : (
<>
<div className="text-muted-foreground text-sm order-2 text-nowrap sm:order-1">
{paginationInfo}
</div>
{pageCount > 1 && (
<div className="order-1 flex items-center gap-1 sm:order-2">
<Button
size="icon-sm"
variant="ghost"
className={btnArrowClasses}
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<span className="sr-only">
{mergedProps.previousPageLabel}
</span>
<ChevronLeftIcon className="size-4" />
</Button>
{renderEllipsisPrevButton()}
{renderPageButtons()}
{renderEllipsisNextButton()}
<Button
size="icon-sm"
variant="ghost"
className={btnArrowClasses}
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
<span className="sr-only">{mergedProps.nextPageLabel}</span>
<ChevronRightIcon className="size-4" />
</Button>
</div>
)}
</>
)}
</div>
</div>
)
}
export { DataGridPagination, type DataGridPaginationProps }
@@ -0,0 +1,419 @@
import {
type PointerEvent,
type ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
import { cn } from "@telemt/ui/lib/utils"
const MIN_THUMB_SIZE = 24
const FALLBACK_SCROLLBAR_SIZE = 12
const INITIAL_METRICS = {
hasVerticalOverflow: false,
headerHeight: 0,
horizontalScrollbarSize: 0,
thumbHeight: 0,
thumbTop: 0,
trackHeight: 0,
} as const
type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both"
type ScrollbarMetrics = {
hasVerticalOverflow: boolean
headerHeight: number
horizontalScrollbarSize: number
thumbHeight: number
thumbTop: number
trackHeight: number
}
type ObservedElements = {
header: HTMLElement | null
horizontalScrollbar: HTMLElement | null
table: HTMLElement | null
tableViewport: HTMLElement | null
}
type DataGridScrollAreaProps = Omit<
ScrollAreaPrimitive.Root.Props,
"children"
> & {
children: ReactNode
orientation?: DataGridScrollAreaOrientation
}
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
}
function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) {
return (
next.hasVerticalOverflow === prev.hasVerticalOverflow &&
next.headerHeight === prev.headerHeight &&
next.horizontalScrollbarSize === prev.horizontalScrollbarSize &&
next.thumbHeight === prev.thumbHeight &&
next.thumbTop === prev.thumbTop &&
next.trackHeight === prev.trackHeight
)
}
function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) {
element.style.setProperty(
"--data-grid-scrollbar-header-height",
`${metrics.headerHeight}px`
)
element.style.setProperty(
"--data-grid-scrollbar-thumb-height",
`${metrics.thumbHeight}px`
)
element.style.setProperty(
"--data-grid-scrollbar-thumb-top",
`${metrics.thumbTop}px`
)
element.style.setProperty(
"--data-grid-scrollbar-track-height",
`${metrics.trackHeight}px`
)
}
function DataGridScrollArea({
children,
className,
orientation = "both",
...props
}: DataGridScrollAreaProps) {
const { props: dataGridProps } = useDataGrid()
const containerRef = useRef<HTMLDivElement>(null)
const viewportRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<{
pointerId: number
startScrollTop: number
startY: number
} | null>(null)
const metricsRef = useRef<ScrollbarMetrics>(INITIAL_METRICS)
const observedElementsRef = useRef<ObservedElements>({
header: null,
horizontalScrollbar: null,
table: null,
tableViewport: null,
})
const showHorizontal = orientation !== "vertical"
const showVertical = orientation !== "horizontal"
const usesCustomVerticalScrollbar =
showVertical && !!dataGridProps.tableLayout?.headerSticky
const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =
useState(false)
const clearDragState = useCallback(() => {
dragRef.current = null
document.body.style.userSelect = ""
document.body.style.webkitUserSelect = ""
}, [])
const resetMetrics = useCallback(() => {
const container = containerRef.current
if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {
applyMetrics(container, INITIAL_METRICS)
metricsRef.current = INITIAL_METRICS
}
setHasCustomVerticalOverflow((prev) => (prev ? false : prev))
}, [])
const syncCustomVerticalScrollbar = useCallback(() => {
const container = containerRef.current
const viewport = viewportRef.current
if (!container || !viewport || !usesCustomVerticalScrollbar) {
resetMetrics()
return
}
const { header, horizontalScrollbar } = observedElementsRef.current
const headerHeight = header?.getBoundingClientRect().height ?? 0
const viewportHeight = viewport.clientHeight
const viewportWidth = viewport.clientWidth
const scrollHeight = viewport.scrollHeight
const scrollWidth = viewport.scrollWidth
const hasHorizontalOverflow =
showHorizontal && scrollWidth > viewportWidth + 0.5
const horizontalScrollbarSize = hasHorizontalOverflow
? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE
: 0
const trackHeight = Math.max(
0,
viewportHeight - headerHeight - horizontalScrollbarSize
)
const maxScroll = Math.max(0, scrollHeight - viewportHeight)
let nextMetrics: ScrollbarMetrics
if (trackHeight === 0 || maxScroll === 0) {
nextMetrics = {
hasVerticalOverflow: false,
headerHeight,
horizontalScrollbarSize,
thumbHeight: trackHeight,
thumbTop: 0,
trackHeight,
}
} else {
const bodyContentHeight = Math.max(
trackHeight,
scrollHeight - headerHeight
)
const thumbHeight = clamp(
trackHeight * (trackHeight / bodyContentHeight),
MIN_THUMB_SIZE,
trackHeight
)
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const thumbTop =
maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0
nextMetrics = {
hasVerticalOverflow: true,
headerHeight,
horizontalScrollbarSize,
thumbHeight,
thumbTop,
trackHeight,
}
}
if (!areMetricsEqual(nextMetrics, metricsRef.current)) {
applyMetrics(container, nextMetrics)
metricsRef.current = nextMetrics
}
setHasCustomVerticalOverflow((prev) =>
prev === nextMetrics.hasVerticalOverflow
? prev
: nextMetrics.hasVerticalOverflow
)
}, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar])
useEffect(() => {
const container = containerRef.current
const viewport = viewportRef.current
if (!container || !viewport) return
if (!usesCustomVerticalScrollbar) {
resetMetrics()
return
}
observedElementsRef.current = {
header: container.querySelector(
'[data-slot="data-grid-table"] thead'
) as HTMLElement | null,
horizontalScrollbar: container.querySelector(
'[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]'
) as HTMLElement | null,
table: container.querySelector(
'[data-slot="data-grid-table"]'
) as HTMLElement | null,
tableViewport: container.querySelector(
'[data-slot="data-grid-table-viewport"]'
) as HTMLElement | null,
}
let frame = 0
const scheduleSync = () => {
cancelAnimationFrame(frame)
frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)
}
scheduleSync()
viewport.addEventListener("scroll", scheduleSync, { passive: true })
const observer =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(scheduleSync)
observer?.observe(viewport)
observedElementsRef.current.header &&
observer?.observe(observedElementsRef.current.header)
observedElementsRef.current.table &&
observer?.observe(observedElementsRef.current.table)
observedElementsRef.current.tableViewport &&
observer?.observe(observedElementsRef.current.tableViewport)
return () => {
cancelAnimationFrame(frame)
observer?.disconnect()
viewport.removeEventListener("scroll", scheduleSync)
clearDragState()
}
}, [
clearDragState,
resetMetrics,
syncCustomVerticalScrollbar,
usesCustomVerticalScrollbar,
])
const scrollToThumbOffset = (nextThumbTop: number) => {
const viewport = viewportRef.current
const { thumbHeight, trackHeight } = metricsRef.current
if (!viewport) return
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
if (maxScroll === 0 || maxThumbTop === 0) {
viewport.scrollTop = 0
return
}
const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop
viewport.scrollTop = ratio * maxScroll
}
const handleThumbPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current
if (!viewport) return
event.preventDefault()
event.stopPropagation()
event.currentTarget.setPointerCapture(event.pointerId)
dragRef.current = {
pointerId: event.pointerId,
startScrollTop: viewport.scrollTop,
startY: event.clientY,
}
document.body.style.userSelect = "none"
document.body.style.webkitUserSelect = "none"
}
const handleThumbPointerMove = (event: PointerEvent<HTMLDivElement>) => {
const viewport = viewportRef.current
const dragState = dragRef.current
const { thumbHeight, trackHeight } = metricsRef.current
if (!viewport || !dragState || dragState.pointerId !== event.pointerId) {
return
}
const maxThumbTop = Math.max(0, trackHeight - thumbHeight)
const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)
if (maxThumbTop === 0 || maxScroll === 0) return
const deltaY = event.clientY - dragState.startY
const nextScrollTop =
dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll
viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll)
}
const handleThumbPointerUp = (event: PointerEvent<HTMLDivElement>) => {
if (dragRef.current?.pointerId !== event.pointerId) return
clearDragState()
}
const handleTrackPointerDown = (event: PointerEvent<HTMLDivElement>) => {
const { thumbHeight } = metricsRef.current
if (event.target !== event.currentTarget) return
event.preventDefault()
event.stopPropagation()
const rect = event.currentTarget.getBoundingClientRect()
const offsetY = event.clientY - rect.top - thumbHeight / 2
scrollToThumbOffset(offsetY)
}
return (
<div ref={containerRef} className="relative">
<ScrollAreaPrimitive.Root
data-slot="data-grid-scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
ref={viewportRef}
data-slot="scroll-area-viewport"
className="size-full"
>
<ScrollAreaPrimitive.Content data-slot="scroll-area-content">
{children}
</ScrollAreaPrimitive.Content>
</ScrollAreaPrimitive.Viewport>
{showHorizontal && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="horizontal"
orientation="horizontal"
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className="bg-border rounded-full relative flex-1"
/>
</ScrollAreaPrimitive.Scrollbar>
)}
{showVertical && !usesCustomVerticalScrollbar && (
<ScrollAreaPrimitive.Scrollbar
data-slot="data-grid-scrollbar"
data-orientation="vertical"
orientation="vertical"
className="flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent"
>
<ScrollAreaPrimitive.Thumb
data-slot="data-grid-thumb"
className="bg-border rounded-full relative flex-1"
/>
</ScrollAreaPrimitive.Scrollbar>
)}
</ScrollAreaPrimitive.Root>
{usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-e-0 top-(--data-grid-scrollbar-header-height) z-20 h-(--data-grid-scrollbar-track-height)"
>
<div
className="pointer-events-auto relative h-full w-2 touch-none p-px"
onPointerDown={handleTrackPointerDown}
>
<div
className={cn(
"bg-border absolute end-px w-2",
"top-(--data-grid-scrollbar-thumb-top) h-(--data-grid-scrollbar-thumb-height)",
"rounded-full"
)}
onLostPointerCapture={clearDragState}
onPointerCancel={handleThumbPointerUp}
onPointerDown={handleThumbPointerDown}
onPointerMove={handleThumbPointerMove}
onPointerUp={handleThumbPointerUp}
/>
</div>
</div>
)}
</div>
)
}
export { DataGridScrollArea }
export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }
@@ -0,0 +1,309 @@
"use client"
import {
createContext,
type CSSProperties,
type ReactNode,
useContext,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
MouseSensor,
TouchSensor,
type UniqueIdentifier,
useSensor,
useSensors,
type DragEndEvent,
type Modifier,
} from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import {
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { type Cell, flexRender, type HeaderGroup, type Row } from "@tanstack/react-table"
import { cn } from "@telemt/ui/lib/utils"
import { Button } from "@telemt/ui/components/button"
import { GripHorizontalIcon } from "lucide-react"
// Context to share sortable listeners from row to handle
type SortableContextValue = ReturnType<typeof useSortable>
const SortableRowContext = createContext<Pick<
SortableContextValue,
"attributes" | "listeners"
> | null>(null)
function DataGridTableDndRowHandle({ className }: { className?: string }) {
const context = useContext(SortableRowContext)
if (!context) {
// Fallback if context is not available (shouldn't happen in normal usage)
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
disabled
>
<GripHorizontalIcon
/>
</Button>
)
}
return (
<Button
variant="ghost"
size="icon-sm"
className={cn(
"size-7 cursor-grab opacity-70 hover:bg-transparent hover:opacity-100 active:cursor-grabbing",
className
)}
{...context.attributes}
{...context.listeners}
>
<GripHorizontalIcon
/>
</Button>
)
}
function DataGridTableDndRow<TData>({ row }: { row: Row<TData> }) {
const {
transform,
transition,
setNodeRef,
isDragging,
attributes,
listeners,
} = useSortable({
id: row.id,
})
const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition: transition,
opacity: isDragging ? 0.8 : 1,
zIndex: isDragging ? 1 : 0,
position: "relative",
cursor: isDragging ? "grabbing" : undefined,
}
return (
<SortableRowContext.Provider value={{ attributes, listeners }}>
<DataGridTableBodyRow
row={row}
dndRef={setNodeRef}
dndStyle={style}
key={row.id}
>
{row.getVisibleCells().map((cell: Cell<TData, unknown>, colIndex) => {
return (
<DataGridTableBodyRowCell cell={cell} key={colIndex}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
})}
</DataGridTableBodyRow>
</SortableRowContext.Provider>
)
}
function DataGridTableDndRows<TData>({
handleDragEnd,
dataIds,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
dataIds: UniqueIdentifier[]
footerContent?: ReactNode
}) {
const { table, isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
const tableContainerRef = useRef<HTMLDivElement>(null)
const [isDraggingRow, setIsDraggingRow] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
useEffect(() => {
if (!isDraggingRow) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingRow])
const modifiers = useMemo(() => {
const restrictToTableContainer: Modifier = ({
transform,
draggingNodeRect,
}) => {
if (!tableContainerRef.current || !draggingNodeRect) {
return transform
}
const containerRect = tableContainerRef.current.getBoundingClientRect()
const { x, y } = transform
const minX = containerRect.left - draggingNodeRect.left
const maxX = containerRect.right - draggingNodeRect.right
const minY = containerRect.top - draggingNodeRect.top
const maxY = containerRect.bottom - draggingNodeRect.bottom
return {
...transform,
x: Math.max(minX, Math.min(maxX, x)),
y: Math.max(minY, Math.min(maxY, y)),
}
}
return [restrictToVerticalAxis, restrictToTableContainer]
}, [])
return (
<DndContext
id={useId()}
collisionDetection={closestCenter}
modifiers={modifiers}
onDragCancel={() => setIsDraggingRow(false)}
onDragEnd={(event) => {
setIsDraggingRow(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingRow(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={tableContainerRef}
className={
isDraggingRow
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
{headerGroup.headers.map((header, index) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={index}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
{props.loadingMode === "skeleton" &&
isLoading &&
pagination?.pageSize ? (
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
</DataGridTableBodyRowSkeleton>
))
) : table.getRowModel().rows.length ? (
<SortableContext
items={dataIds}
strategy={verticalListSortingStrategy}
>
{table.getRowModel().rows.map((row: Row<TData>) => {
return <DataGridTableDndRow row={row} key={row.id} />
})}
</SortableContext>
) : (
<DataGridTableEmpty />
)}
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDndRowHandle, DataGridTableDndRows }
@@ -0,0 +1,312 @@
import {
type CSSProperties,
Fragment,
type ReactNode,
useEffect,
useId,
useRef,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableBodyRow,
DataGridTableBodyRowCell,
DataGridTableBodyRowExpandded,
DataGridTableBodyRowSkeleton,
DataGridTableBodyRowSkeletonCell,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRowSpacer,
DataGridTableViewport,
} from "@/components/reui/data-grid/data-grid-table"
import {
closestCenter,
DndContext,
KeyboardSensor,
type Modifier,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core"
import {
horizontalListSortingStrategy,
SortableContext,
useSortable,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import {
type Cell,
flexRender,
type Header,
type HeaderGroup,
type Row,
} from "@tanstack/react-table"
import { Button } from "@telemt/ui/components/button"
import { GripVerticalIcon } from "lucide-react"
function DataGridTableDndHeader<TData>({
header,
}: {
header: Header<TData, unknown>
}) {
const { props } = useDataGrid()
const { column } = header
// Check if column ordering is enabled for this column
const canOrder =
(column.columnDef as { enableColumnOrdering?: boolean })
.enableColumnOrdering !== false
const {
attributes,
isDragging,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({
id: header.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
whiteSpace: "nowrap",
width: props.tableLayout?.columnsResizable
? `calc(var(--header-${header.id}-size) * 1px)`
: header.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableHeadRowCell
header={header}
dndStyle={style}
dndRef={setNodeRef}
>
<div className="flex items-center justify-start gap-0.5">
{canOrder && (
<Button
size="icon-sm"
variant="ghost"
className={`-ms-2 size-6 ${isDragging ? "cursor-grabbing" : "cursor-grab active:cursor-grabbing"}`}
{...attributes}
{...listeners}
aria-label="Drag to reorder"
>
<GripVerticalIcon className="opacity-60 hover:opacity-100" aria-hidden="true" />
</Button>
)}
<span className="grow truncate">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</span>
{props.tableLayout?.columnsResizable && column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</div>
</DataGridTableHeadRowCell>
)
}
function DataGridTableDndCell<TData>({ cell }: { cell: Cell<TData, unknown> }) {
const { props } = useDataGrid()
const { isDragging, setNodeRef, transform, transition } = useSortable({
id: cell.column.id,
})
const style: CSSProperties = {
opacity: isDragging ? 0.8 : 1,
position: "relative",
transform: CSS.Translate.toString(transform),
transition,
cursor: isDragging ? "grabbing" : undefined,
width: props.tableLayout?.columnsResizable
? `calc(var(--col-${cell.column.id}-size) * 1px)`
: cell.column.getSize(),
zIndex: isDragging ? 1 : 0,
}
return (
<DataGridTableBodyRowCell cell={cell} dndStyle={style} dndRef={setNodeRef}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</DataGridTableBodyRowCell>
)
}
function DataGridTableDnd<TData>({
handleDragEnd,
footerContent,
}: {
handleDragEnd: (event: DragEndEvent) => void
footerContent?: ReactNode
}) {
const { table, isLoading, props } = useDataGrid()
const pagination = table.getState().pagination
const containerRef = useRef<HTMLDivElement>(null)
const [isDraggingColumn, setIsDraggingColumn] = useState(false)
const sensors = useSensors(
useSensor(MouseSensor, {}),
useSensor(TouchSensor, {}),
useSensor(KeyboardSensor, {})
)
useEffect(() => {
if (!isDraggingColumn) return
const { body, documentElement } = document
const previousBodyCursor = body.style.cursor
const previousDocumentCursor = documentElement.style.cursor
body.style.cursor = "grabbing"
documentElement.style.cursor = "grabbing"
return () => {
body.style.cursor = previousBodyCursor
documentElement.style.cursor = previousDocumentCursor
}
}, [isDraggingColumn])
// Custom modifier to restrict dragging within table bounds with edge offset
const restrictToTableBounds: Modifier = ({ draggingNodeRect, transform }) => {
if (!draggingNodeRect || !containerRef.current) {
return { ...transform, y: 0 }
}
const containerRect = containerRef.current.getBoundingClientRect()
const edgeOffset = 0
const minX = containerRect.left - draggingNodeRect.left - edgeOffset
const maxX =
containerRect.right -
draggingNodeRect.left -
draggingNodeRect.width +
edgeOffset
return {
...transform,
x: Math.min(Math.max(transform.x, minX), maxX),
y: 0, // Lock vertical movement
}
}
return (
<DndContext
collisionDetection={closestCenter}
id={useId()}
modifiers={[restrictToTableBounds]}
onDragCancel={() => setIsDraggingColumn(false)}
onDragEnd={(event) => {
setIsDraggingColumn(false)
handleDragEnd(event)
}}
onDragStart={() => setIsDraggingColumn(true)}
sensors={sensors}
>
<DataGridTableViewport
viewportRef={containerRef}
className={
isDraggingColumn
? "relative cursor-grabbing [&_*]:cursor-grabbing!"
: "relative"
}
>
<DataGridTableBase>
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => {
return (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
<SortableContext
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
{headerGroup.headers.map((header) => (
<DataGridTableDndHeader
header={header}
key={header.id}
/>
))}
</SortableContext>
</DataGridTableHeadRow>
)
})}
</DataGridTableHead>
{(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
{props.loadingMode === "skeleton" &&
isLoading &&
pagination?.pageSize ? (
Array.from({ length: pagination.pageSize }).map((_, rowIndex) => (
<DataGridTableBodyRowSkeleton key={rowIndex}>
{table.getVisibleFlatColumns().map((column, colIndex) => {
return (
<DataGridTableBodyRowSkeletonCell
column={column}
key={colIndex}
>
{column.columnDef.meta?.skeleton}
</DataGridTableBodyRowSkeletonCell>
)
})}
</DataGridTableBodyRowSkeleton>
))
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row: Row<TData>) => {
return (
<Fragment key={row.id}>
<DataGridTableBodyRow row={row}>
{row
.getVisibleCells()
.map((cell: Cell<TData, unknown>) => {
return (
<SortableContext
key={cell.id}
items={table.getState().columnOrder}
strategy={horizontalListSortingStrategy}
>
<DataGridTableDndCell cell={cell} />
</SortableContext>
)
})}
</DataGridTableBodyRow>
{row.getIsExpanded() && (
<DataGridTableBodyRowExpandded row={row} />
)}
</Fragment>
)
})
) : (
<DataGridTableEmpty />
)}
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
</DndContext>
)
}
export { DataGridTableDnd }
@@ -0,0 +1,493 @@
"use client"
import {
memo,
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react"
import { useDataGrid } from "@/components/reui/data-grid/data-grid"
import {
DataGridTableBase,
DataGridTableBody,
DataGridTableEmpty,
DataGridTableFoot,
DataGridTableHead,
DataGridTableHeadRow,
DataGridTableHeadRowCell,
DataGridTableHeadRowCellResize,
DataGridTableRenderedRow,
DataGridTableRowSpacer,
DataGridTableViewport,
getDataGridTableRowSections,
} from "@/components/reui/data-grid/data-grid-table"
import { flexRender, type HeaderGroup, type Row, type Table } from "@tanstack/react-table"
import {
useVirtualizer,
type VirtualItem,
type Virtualizer,
type VirtualizerOptions,
} from "@tanstack/react-virtual"
import { cn } from "@telemt/ui/lib/utils"
import { Spinner } from "@telemt/ui/components/spinner"
type DataGridTableVirtualScrollElements = {
containerElement: HTMLDivElement | null
scrollElement: HTMLElement | null
}
type DataGridTableVirtualizerInstance = Virtualizer<
HTMLElement,
HTMLTableRowElement
>
type DataGridTableVirtualizerOptions<TData> = Omit<
VirtualizerOptions<HTMLElement, HTMLTableRowElement>,
"count" | "estimateSize" | "getItemKey" | "getScrollElement"
> & {
estimateSize?: (index: number, row: Row<TData>) => number
getItemKey?: (index: number, row: Row<TData>) => string | number
getScrollElement?: (
elements: DataGridTableVirtualScrollElements
) => HTMLElement | null
}
interface DataGridTableVirtualProps<TData> {
height?: number | string
estimateSize?: number
overscan?: number
footerContent?: ReactNode
renderHeader?: boolean
onFetchMore?: () => void
isFetchingMore?: boolean
hasMore?: boolean
fetchMoreOffset?: number
virtualizerOptions?: DataGridTableVirtualizerOptions<TData>
}
interface VirtualBodyProps<TData> {
table: Table<TData>
columnCount: number
topRows: Row<TData>[]
centerRows: Row<TData>[]
bottomRows: Row<TData>[]
virtualItems: VirtualItem[]
totalSize: number
isVirtualizationEnabled: boolean
isInfiniteMode: boolean
isFetchingMore: boolean
hasMore?: boolean
loadingMoreMessage: ReactNode
allRowsLoadedMessage: ReactNode
measureRowRef?: (element: HTMLTableRowElement | null) => void
}
function DataGridTableVirtualSpacer({
columnCount,
height,
}: {
columnCount: number
height: number
}) {
if (height <= 0) return null
return (
<tr aria-hidden="true">
<td colSpan={columnCount} style={{ height, padding: 0 }} />
</tr>
)
}
function DataGridTableVirtualStatusRow({
children,
className,
columnCount,
}: {
children: ReactNode
className?: string
columnCount: number
}) {
return (
<tr>
<td
colSpan={columnCount}
className={cn(
"text-muted-foreground py-4 text-center text-sm",
className
)}
>
{children}
</td>
</tr>
)
}
function DataGridTableVirtualBody<TData>({
table: _table,
columnCount,
topRows,
centerRows,
bottomRows,
virtualItems,
totalSize,
isVirtualizationEnabled,
isInfiniteMode,
isFetchingMore,
hasMore,
loadingMoreMessage,
allRowsLoadedMessage,
measureRowRef,
}: VirtualBodyProps<TData>) {
void _table
const totalRows = topRows.length + centerRows.length + bottomRows.length
if (!totalRows) return <DataGridTableEmpty />
const hasCenterRows = centerRows.length > 0
const showFetchingRow = isInfiniteMode && isFetchingMore
const showCompleteRow = isInfiniteMode && hasMore === false && totalRows > 0
const hasMiddleSection = hasCenterRows || showFetchingRow || showCompleteRow
const leadingSpacerHeight =
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
? (virtualItems[0]?.start ?? 0)
: 0
const trailingSpacerHeight =
isVirtualizationEnabled && hasCenterRows && virtualItems.length > 0
? Math.max(
0,
totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)
)
: 0
const renderedRows: ReactNode[] = []
topRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={
index === topRows.length - 1 && hasMiddleSection ? "top" : undefined
}
/>
)
})
if (isVirtualizationEnabled) {
if (leadingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-start"
columnCount={columnCount}
height={leadingSpacerHeight}
/>
)
}
virtualItems.forEach((virtualRow) => {
const row = centerRows[virtualRow.index]
if (!row) return
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
rowRef={measureRowRef}
/>
)
})
if (trailingSpacerHeight > 0) {
renderedRows.push(
<DataGridTableVirtualSpacer
key="virtual-spacer-end"
columnCount={columnCount}
height={trailingSpacerHeight}
/>
)
}
} else {
centerRows.forEach((row) => {
renderedRows.push(<DataGridTableRenderedRow key={row.id} row={row} />)
})
}
if (showFetchingRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow
key="virtual-status-loading"
columnCount={columnCount}
>
<div className="flex items-center justify-center gap-2">
<Spinner className="size-4 opacity-60" />
{loadingMoreMessage}
</div>
</DataGridTableVirtualStatusRow>
)
}
if (showCompleteRow) {
renderedRows.push(
<DataGridTableVirtualStatusRow
key="virtual-status-complete"
columnCount={columnCount}
className="py-3 text-xs"
>
{allRowsLoadedMessage}
</DataGridTableVirtualStatusRow>
)
}
bottomRows.forEach((row, index) => {
renderedRows.push(
<DataGridTableRenderedRow
key={row.id}
row={row}
pinnedBoundary={
index === 0 && (topRows.length > 0 || hasMiddleSection)
? "bottom"
: undefined
}
/>
)
})
return <>{renderedRows}</>
}
/**
* Memoized virtual body: skip re-renders during active column resize.
* Column widths update via CSS variables on the <table> element,
* so the browser handles width changes without React re-renders.
*/
const MemoizedVirtualBody = memo(
DataGridTableVirtualBody,
(_prev, next) => !!next.table.getState().columnSizingInfo.isResizingColumn
) as typeof DataGridTableVirtualBody
function DataGridTableVirtual<TData>({
height,
estimateSize = 48,
overscan = 10,
footerContent,
renderHeader = true,
onFetchMore,
isFetchingMore = false,
hasMore,
fetchMoreOffset = 0,
virtualizerOptions,
}: DataGridTableVirtualProps<TData>) {
const { table, props } = useDataGrid()
const { topRows, centerRows, bottomRows } = getDataGridTableRowSections(
table,
props.tableLayout?.rowsPinnable
)
const columnCount =
table.getVisibleFlatColumns().length +
(props.tableLayout?.columnsResizable ? 1 : 0)
const isInfiniteMode = typeof onFetchMore === "function"
const [viewportElements, setViewportElements] =
useState<DataGridTableVirtualScrollElements>({
containerElement: null,
scrollElement: null,
})
const {
estimateSize: customEstimateSize,
getItemKey: customGetItemKey,
getScrollElement: customGetScrollElement,
measureElement: customMeasureElement,
overscan: customOverscan,
...virtualizerOptionsRest
} = virtualizerOptions ?? {}
const isVirtualizationEnabled = virtualizerOptions?.enabled !== false
const loadingMoreMessage =
props.fetchingMoreMessage || props.loadingMessage || "Loading..."
const allRowsLoadedMessage =
props.allRowsLoadedMessage || "All records loaded"
const handleViewportRef = useCallback((node: HTMLDivElement | null) => {
setViewportElements({
containerElement: node,
scrollElement:
(node?.closest(
'[data-slot="scroll-area-viewport"]'
) as HTMLElement | null) ?? node,
})
}, [])
const usesExternalScrollArea =
viewportElements.scrollElement !== null &&
viewportElements.scrollElement !== viewportElements.containerElement
const resolveScrollElement = useCallback(() => {
if (customGetScrollElement) {
return customGetScrollElement(viewportElements)
}
return viewportElements.scrollElement
}, [customGetScrollElement, viewportElements])
const resolveItemKey = useCallback(
(index: number) => {
const row = centerRows[index]
if (!row) return index
return customGetItemKey?.(index, row) ?? row.id ?? index
},
[centerRows, customGetItemKey]
)
const resolveEstimateSize = useCallback(
(index: number) => {
const row = centerRows[index]
return row
? (customEstimateSize?.(index, row) ?? estimateSize)
: estimateSize
},
[centerRows, customEstimateSize, estimateSize]
)
const virtualizer = useVirtualizer({
count: centerRows.length,
getScrollElement: resolveScrollElement,
getItemKey: resolveItemKey,
estimateSize: resolveEstimateSize,
overscan: customOverscan ?? overscan,
measureElement: customMeasureElement,
...virtualizerOptionsRest,
}) as DataGridTableVirtualizerInstance
const virtualItems = isVirtualizationEnabled
? virtualizer.getVirtualItems()
: []
const totalSize = isVirtualizationEnabled ? virtualizer.getTotalSize() : 0
const measureRowRef =
isVirtualizationEnabled && customMeasureElement
? virtualizer.measureElement
: undefined
const resolvedFetchMoreOffset = useMemo(
() => Math.max(0, fetchMoreOffset),
[fetchMoreOffset]
)
useEffect(() => {
if (
!isVirtualizationEnabled ||
!isInfiniteMode ||
hasMore === false ||
isFetchingMore
) {
return
}
const lastItem = virtualItems[virtualItems.length - 1]
if (!lastItem) return
if (lastItem.index >= centerRows.length - 1 - resolvedFetchMoreOffset) {
onFetchMore?.()
}
}, [
centerRows.length,
hasMore,
isFetchingMore,
isInfiniteMode,
isVirtualizationEnabled,
onFetchMore,
resolvedFetchMoreOffset,
virtualItems,
])
return (
<DataGridTableViewport
viewportRef={handleViewportRef}
className={!usesExternalScrollArea ? "block" : undefined}
style={
usesExternalScrollArea
? undefined
: { height, overflow: "auto", position: "relative" }
}
>
<DataGridTableBase>
{renderHeader && (
<DataGridTableHead>
{table
.getHeaderGroups()
.map((headerGroup: HeaderGroup<TData>, index) => (
<DataGridTableHeadRow headerGroup={headerGroup} key={index}>
{headerGroup.headers.map((header, hIndex) => {
const { column } = header
return (
<DataGridTableHeadRowCell header={header} key={hIndex}>
{header.isPlaceholder ? null : props.tableLayout
?.columnsResizable && column.getCanResize() ? (
<div className="truncate">
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
{props.tableLayout?.columnsResizable &&
column.getCanResize() && (
<DataGridTableHeadRowCellResize header={header} />
)}
</DataGridTableHeadRowCell>
)
})}
</DataGridTableHeadRow>
))}
</DataGridTableHead>
)}
{renderHeader &&
(props.tableLayout?.stripped || !props.tableLayout?.rowBorder) && (
<DataGridTableRowSpacer />
)}
<DataGridTableBody>
<MemoizedVirtualBody
table={table}
columnCount={columnCount}
topRows={topRows}
centerRows={centerRows}
bottomRows={bottomRows}
virtualItems={virtualItems}
totalSize={totalSize}
isVirtualizationEnabled={isVirtualizationEnabled}
isInfiniteMode={isInfiniteMode}
isFetchingMore={isFetchingMore}
hasMore={hasMore}
loadingMoreMessage={loadingMoreMessage}
allRowsLoadedMessage={allRowsLoadedMessage}
measureRowRef={measureRowRef}
/>
</DataGridTableBody>
{footerContent && (
<DataGridTableFoot>{footerContent}</DataGridTableFoot>
)}
</DataGridTableBase>
</DataGridTableViewport>
)
}
export { DataGridTableVirtual }
export type {
DataGridTableVirtualProps,
DataGridTableVirtualScrollElements,
DataGridTableVirtualizerOptions,
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,270 @@
"use client"
import { createContext, type ReactNode, useContext, useMemo } from "react"
import {
type Column,
type ColumnFiltersState,
type RowData,
type SortingState,
type Table,
} from "@tanstack/react-table"
import { cn } from "@telemt/ui/lib/utils"
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface ColumnMeta<TData extends RowData, TValue> {
headerTitle?: string
headerClassName?: string
cellClassName?: string
skeleton?: ReactNode
expandedContent?: (row: TData) => ReactNode
}
}
/** Label for headers / column visibility: `meta.headerTitle`, string `columnDef.header`, or `column.id`. */
export function getColumnHeaderLabel<TData, TValue>(
column: Column<TData, TValue>
): string {
const meta = column.columnDef.meta as { headerTitle?: string } | undefined
if (typeof meta?.headerTitle === "string") return meta.headerTitle
const defHeader = column.columnDef.header
if (typeof defHeader === "string") return defHeader
return String(column.id)
}
export type DataGridApiFetchParams = {
pageIndex: number
pageSize: number
sorting?: SortingState
filters?: ColumnFiltersState
searchQuery?: string
}
export type DataGridApiResponse<T> = {
data: T[]
empty: boolean
pagination: {
total: number
page: number
}
}
export interface DataGridContextProps<TData extends object> {
props: DataGridProps<TData>
table: Table<TData>
recordCount: number
isLoading: boolean
}
export type DataGridRequestParams = {
pageIndex: number
pageSize: number
sorting?: SortingState
columnFilters?: ColumnFiltersState
}
export interface DataGridProps<TData extends object> {
className?: string
table?: Table<TData>
recordCount: number
children?: ReactNode
onRowClick?: (row: TData) => void
isLoading?: boolean
loadingMode?: "skeleton" | "spinner"
loadingMessage?: ReactNode | string
fetchingMoreMessage?: ReactNode | string
allRowsLoadedMessage?: ReactNode | string
emptyMessage?: ReactNode | string
tableLayout?: {
dense?: boolean
cellBorder?: boolean
rowBorder?: boolean
rowRounded?: boolean
stripped?: boolean
headerBackground?: boolean
headerBorder?: boolean
headerSticky?: boolean
width?: "auto" | "fixed"
columnsVisibility?: boolean
columnsResizable?: boolean
columnsResizeMode?: "onChange" | "onEnd"
columnsPinnable?: boolean
columnsMovable?: boolean
columnsDraggable?: boolean
rowsDraggable?: boolean
rowsPinnable?: boolean
}
tableClassNames?: {
base?: string
header?: string
headerRow?: string
headerSticky?: string
body?: string
bodyRow?: string
footer?: string
edgeCell?: string
}
}
const DataGridContext = createContext<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
DataGridContextProps<any> | undefined
>(undefined)
function useDataGrid() {
const context = useContext(DataGridContext)
if (!context) {
throw new Error("useDataGrid must be used within a DataGridProvider")
}
return context
}
function DataGridProvider<TData extends object>({
children,
table,
...props
}: DataGridProps<TData> & { table: Table<TData> }) {
const tableState = table.getState()
const resolvedColumnsResizeMode =
props.tableLayout?.columnsResizeMode ?? "onEnd"
// Keep resize mode aligned with the DataGrid contract every render so
// consumer-level useReactTable options cannot flip it back between drags.
if (props.tableLayout?.columnsResizable) {
table.options.columnResizeMode = resolvedColumnsResizeMode
}
// Memoize context value so consumers don't re-render during column resize.
// Column sizing state is intentionally excluded from deps -- CSS variables
// on the <table> element handle width updates without React re-renders.
const value = useMemo(
() => ({
props,
table,
recordCount: props.recordCount,
isLoading: props.isLoading || false,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
table,
props.recordCount,
props.isLoading,
props.loadingMode,
props.loadingMessage,
props.fetchingMoreMessage,
props.allRowsLoadedMessage,
props.emptyMessage,
props.onRowClick,
props.className,
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(props.tableLayout),
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(props.tableClassNames),
tableState.sorting,
tableState.pagination,
tableState.columnFilters,
tableState.rowSelection,
tableState.expanded,
tableState.columnVisibility,
tableState.columnOrder,
tableState.columnPinning,
tableState.globalFilter,
]
)
return (
<DataGridContext.Provider value={value}>
{children}
</DataGridContext.Provider>
)
}
function DataGrid<TData extends object>({
children,
table,
...props
}: DataGridProps<TData>) {
const defaultProps: Partial<DataGridProps<TData>> = {
loadingMode: "skeleton",
tableLayout: {
dense: false,
cellBorder: false,
rowBorder: true,
rowRounded: false,
stripped: false,
headerSticky: false,
headerBackground: true,
headerBorder: true,
width: "fixed",
columnsVisibility: false,
columnsResizable: false,
columnsResizeMode: "onEnd",
columnsPinnable: false,
columnsMovable: false,
columnsDraggable: false,
rowsDraggable: false,
rowsPinnable: false,
},
tableClassNames: {
base: "",
header: "",
headerRow: "",
headerSticky: "sticky top-0 z-15 bg-background/90 backdrop-blur-xs",
body: "",
bodyRow: "",
footer: "",
edgeCell: "",
},
}
const mergedProps: DataGridProps<TData> = {
...defaultProps,
...props,
tableLayout: {
...defaultProps.tableLayout,
...(props.tableLayout || {}),
},
tableClassNames: {
...defaultProps.tableClassNames,
...(props.tableClassNames || {}),
},
}
// Ensure table is provided
if (!table) {
throw new Error('DataGrid requires a "table" prop')
}
return (
<DataGridProvider table={table} {...mergedProps}>
{children}
</DataGridProvider>
)
}
function DataGridContainer({
children,
className,
border = true,
}: {
children: ReactNode
className?: string
border?: boolean
}) {
return (
<div
data-slot="data-grid"
className={cn(
"w-full overflow-hidden",
border &&
"border-border rounded-lg border",
className
)}
>
{children}
</div>
)
}
export { useDataGrid, DataGridProvider, DataGrid, DataGridContainer }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+194
View File
@@ -0,0 +1,194 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@telemt/ui/lib/utils"
/**
* CSS variable architecture for FramePanel theming:
*
* The Frame parent sets --frame-panel-bg and --frame-panel-border-color.
* FramePanel consumes them directly via bg-(--frame-panel-bg) and
* border-(--frame-panel-border-color). This means:
*
* - variant="inverse" overrides those vars on Frame → all panels pick it up
* - <FramePanel className="bg-blue-50"> adds a direct utility on the element
* which wins over bg-(--frame-panel-bg) by Tailwind source order — no
* :not() or !important needed
*/
const frameVariants = cva(
[
"relative flex flex-col bg-muted/50 gap-(--frame-gap) px-(--frame-px) py-(--frame-py) rounded-(--frame-radius)",
"(--radius-xl)] [--frame-radius:var(--radius-xl)]",
"(--radius-none)] (--radius-2xl)] (--radius-lg)] (--radius-none)]",
"[--frame-gap:--spacing(0.75)] [--frame-px:--spacing(0.75)] [--frame-py:--spacing(0.75)] [--frame-panel-header-gap:0rem] [--frame-panel-footer-gap:--spacing(1)]",
"[--frame-panel-px-adjust:0px] [--frame-panel-py-adjust:0px] [--frame-panel-header-px-adjust:0px] [--frame-panel-header-py-adjust:0px] [--frame-panel-footer-px-adjust:0px] [--frame-panel-footer-py-adjust:0px]",
"[--frame-panel-px:calc(var(--frame-panel-px-base)_+_var(--frame-panel-px-adjust))] [--frame-panel-py:calc(var(--frame-panel-py-base)_+_var(--frame-panel-py-adjust))] [--frame-panel-header-px:calc(var(--frame-panel-header-px-base)_+_var(--frame-panel-header-px-adjust))] [--frame-panel-header-py:calc(var(--frame-panel-header-py-base)_+_var(--frame-panel-header-py-adjust))] [--frame-panel-footer-px:calc(var(--frame-panel-footer-px-base)_+_var(--frame-panel-footer-px-adjust))] [--frame-panel-footer-py:calc(var(--frame-panel-footer-py-base)_+_var(--frame-panel-footer-py-adjust))]",
"(1)] (1)] (1.25)] (1.5)] (1.5)] (0.5)] (1)] (1)]",
// Default panel token values — overridden per-variant below
"[--frame-panel-bg:var(--color-card)] [--frame-panel-border-color:var(--color-border)] [--frame-border-color:var(--color-border)]",
// Concentric inner radius: the panel corner nests smoothly inside the frame
// corner instead of matching it. The panel sits inset from the frame's outer
// edge by the frame's 1px border + --frame-px padding, so its radius is
// reduced by that same gap (radius gap keeps the two arcs parallel). This
// base value assumes the bordered default/inverse frame; `ghost` drops the
// 1px border term and `dense` pins it back to the frame radius (its panels
// are pulled flush to the edge).
"[--frame-panel-radius:calc(var(--frame-radius)_-_var(--frame-px)_-_1px)]",
],
{
variants: {
variant: {
default: "border border-[var(--frame-border-color)] bg-clip-padding",
inverse:
"[--frame-panel-bg:color-mix(in_oklch,var(--color-muted)_40%,transparent)] border border-[var(--frame-border-color)] bg-background bg-clip-padding",
// No frame border, so the panel is inset by --frame-px padding only.
ghost: "[--frame-panel-radius:calc(var(--frame-radius)_-_var(--frame-px))]",
},
// Header/footer vertical rhythm is tighter than the panel body's, and
// the gap widens as the frame grows: the bars read as chrome rather than
// as another content block. py ladder is 0.5 / 1.5 / 2 / 2.5 against a
// body py of 2 / 3.5 / 4 / 5. These vars are style-agnostic - no
// style-*.css overrides them - so this single ladder drives all shadcn
// styles. `px` is deliberately left level with the body so header,
// content and footer stay left-aligned. `xs` holds at 0.5 (2px): it is
// the practical floor, since anything lower stops reading as padding.
spacing: {
xs: "[--frame-panel-px-base:--spacing(2)] [--frame-panel-py-base:--spacing(2)] [--frame-panel-header-px-base:--spacing(2)] [--frame-panel-header-py-base:--spacing(0.5)] [--frame-panel-footer-px-base:--spacing(2)] [--frame-panel-footer-py-base:--spacing(0.5)]",
sm: "[--frame-panel-px-base:--spacing(3)] [--frame-panel-py-base:--spacing(3.5)] [--frame-panel-header-px-base:--spacing(3)] [--frame-panel-header-py-base:--spacing(1.5)] [--frame-panel-footer-px-base:--spacing(3)] [--frame-panel-footer-py-base:--spacing(1.5)]",
default:
"[--frame-panel-px-base:--spacing(4)] [--frame-panel-py-base:--spacing(4)] [--frame-panel-header-px-base:--spacing(4)] [--frame-panel-header-py-base:--spacing(2)] [--frame-panel-footer-px-base:--spacing(4)] [--frame-panel-footer-py-base:--spacing(2)]",
lg: "[--frame-panel-px-base:--spacing(5)] [--frame-panel-py-base:--spacing(5)] [--frame-panel-header-px-base:--spacing(5)] [--frame-panel-header-py-base:--spacing(2.5)] [--frame-panel-footer-px-base:--spacing(5)] [--frame-panel-footer-py-base:--spacing(2.5)]",
},
stacked: {
true: [
"gap-0 *:has-[+[data-slot=frame-panel]]:rounded-b-none",
"*:has-[+[data-slot=frame-panel]]:before:hidden",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:rounded-t-none",
"*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:border-t-0",
],
false: [
"data-[spacing=sm]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-0.5",
"data-[spacing=default]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-1",
"data-[spacing=lg]:*:[[data-slot=frame-panel]+[data-slot=frame-panel]]:mt-2",
],
},
dense: {
// Positional rules must stay as parent selectors — cannot be expressed via CSS vars.
// Padding is 0 and panels are pulled flush to the frame edge (-mx-px), so
// their corners align with the frame radius rather than nesting inside it.
true: "p-0 gap-0 border-[var(--frame-border-color)] [--frame-panel-radius:var(--frame-radius)] [&_[data-slot=frame-panel]]:-mx-px [&_[data-slot=frame-panel]]:before:hidden [&_[data-slot=frame-panel]:last-child]:-mb-px [&:not(:has([data-slot=frame-panel-header]))_[data-slot=frame-panel]:is(:first-child)]:-mt-px",
false: "",
},
},
defaultVariants: {
variant: "default",
spacing: "default",
stacked: false,
dense: false,
},
}
)
function Frame({
className,
variant,
spacing,
stacked,
dense,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof frameVariants>) {
return (
<div
className={cn(
frameVariants({ variant, spacing, stacked, dense }),
className
)}
data-slot="frame"
data-spacing={spacing}
{...props}
/>
)
}
function FramePanel({
className,
fit,
...props
}: React.ComponentProps<"div"> & { fit?: boolean }) {
return (
<div
className={cn(
// bg-(--frame-panel-bg) and border-(--frame-panel-border-color) consume the
// CSS vars set by the Frame parent. Any explicit bg-* or border-* class passed
// via className overrides these by Tailwind source order - no ! needed.
"relative overflow-hidden rounded-(--frame-panel-radius) border border-(--frame-panel-border-color) bg-(--frame-panel-bg) bg-clip-padding shadow-xs",
// `fit` sizes the panel to its content; otherwise it grows to fill the frame.
!fit && "grow",
"before:pointer-events-none before:absolute before:inset-0 before:rounded-[calc(var(--frame-panel-radius)_-_1px)] before:shadow-black/5",
"dark:bg-clip-border dark:before:shadow-white/5",
"px-(--frame-panel-px) py-(--frame-panel-py)",
className
)}
data-slot="frame-panel"
{...props}
/>
)
}
function FrameHeader({ className, ...props }: React.ComponentProps<"header">) {
return (
<header
className={cn(
"flex flex-col gap-(--frame-panel-header-gap) px-(--frame-panel-header-px) py-(--frame-panel-header-py)",
className
)}
data-slot="frame-panel-header"
{...props}
/>
)
}
function FrameTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
className={cn("text-sm font-semibold", className)}
data-slot="frame-panel-title"
{...props}
/>
)
}
function FrameDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn("text-muted-foreground text-sm", className)}
data-slot="frame-panel-description"
{...props}
/>
)
}
function FrameFooter({ className, ...props }: React.ComponentProps<"footer">) {
return (
<footer
className={cn(
"flex flex-col gap-(--frame-panel-footer-gap) px-(--frame-panel-footer-px) py-(--frame-panel-footer-py)",
className
)}
data-slot="frame-panel-footer"
{...props}
/>
)
}
export {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
FrameFooter,
frameVariants,
}
@@ -0,0 +1,90 @@
import { cn } from "@telemt/ui/lib/utils"
type IconStackProps = React.ComponentProps<"div">
function IconStack({ className, children, style, ...props }: IconStackProps) {
return (
<div
data-slot="icon-stack"
className={cn(
"text-foreground **:data-[slot=icon-stack-layer]:fill-background relative h-20 w-18",
className
)}
style={
{
"--icon-stack-content-x": "71%",
"--icon-stack-content-y": "58%",
...style,
} as React.CSSProperties
}
{...props}
>
<svg
aria-hidden="true"
viewBox="0 0 72 81"
fill="none"
className="h-full w-full overflow-visible"
>
<ellipse
cx="36"
cy="76"
rx="30"
ry="7"
fill="currentColor"
fillOpacity="0.055"
className="blur-[4px]"
/>
<IconStackLayer opacity="0.4" />
<IconStackLayer opacity="0.6" x={13.65} y={6.04} />
<IconStackLayer opacity="0.8" x={27.32} y={12.08} active />
</svg>
{children ? (
<div
data-slot="icon-stack-content"
className="text-muted-foreground pointer-events-none absolute top-[var(--icon-stack-content-y)] left-[var(--icon-stack-content-x)] flex -translate-x-1/2 -translate-y-1/2 scale-x-90 -skew-y-26 items-center justify-center"
>
{children}
</div>
) : null}
</div>
)
}
function IconStackLayer({
active = false,
opacity,
x = 0,
y = 0,
}: {
active?: boolean
opacity: string
x?: number
y?: number
}) {
return (
<g opacity={opacity} transform={`translate(${x} ${y})`}>
<path
data-slot="icon-stack-layer"
d="M42.2538 2.046C41.4408 1.6325 40.3965 1.6677 39.2612 2.2424L7.9616 18.1934C5.3895 19.5039 3.301 23.1064 3.301 26.2322V64.3226C3.301 66.0677 3.9458 67.2943 4.962 67.8199L1.8363 66.229C0.8201 65.7104 0.1753 64.4771 0.1753 62.732V24.6412C0.1753 21.5085 2.2638 17.913 4.8359 16.6024L36.1355 0.6515C37.2778 0.0698 38.322 0.0416 39.128 0.4551L42.2538 2.046Z"
stroke="currentColor"
strokeOpacity={active ? "0.3" : "0.2"}
strokeWidth="0.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
data-slot="icon-stack-layer"
d="M42.2545 2.0456C43.2707 2.5643 43.9155 3.7979 43.9155 5.543V43.6337C43.9155 46.7665 41.827 50.3616 39.2549 51.6722L7.9554 67.6235C6.813 68.2052 5.7687 68.2331 4.9628 67.8196C3.9465 67.301 3.3018 66.0673 3.3018 64.3222V26.2318C3.3018 23.0991 5.3903 19.5036 7.9624 18.193L39.2619 2.2421C40.4043 1.6604 41.4486 1.6321 42.2545 2.0456Z"
stroke="currentColor"
strokeOpacity={active ? "0.3" : "0.2"}
strokeWidth="0.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
)
}
export { IconStack, type IconStackProps }
@@ -0,0 +1,258 @@
import { createContext, type ReactNode, useContext, useId } from "react"
import { NumberField as NumberFieldPrimitive } from "@base-ui/react/number-field"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@telemt/ui/lib/utils"
import { Label } from "@telemt/ui/components/label"
import { MinusIcon, PlusIcon } from "lucide-react"
const NumberFieldContext = createContext<{
fieldId: string
size: "sm" | "default" | "lg"
} | null>(null)
const numberFieldGroupVariants = cva(
"relative flex w-full justify-between border border-input data-disabled:pointer-events-none data-disabled:opacity-50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive focus-within:has-aria-invalid:border-destructive focus-within:has-aria-invalid:ring-destructive/20 dark:focus-within:has-aria-invalid:ring-destructive/40 rounded-lg bg-transparent dark:bg-input/30 transition-colors focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-3",
{
variants: {
size: {
sm: "h-7 text-sm",
default:
"h-8 text-sm",
lg: "h-9 text-sm",
},
},
defaultVariants: {
size: "default",
},
}
)
const numberFieldButtonVariants = cva(
"relative flex shrink-0 cursor-pointer items-center justify-center transition-colors pointer-coarse:after:absolute pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 hover:bg-accent",
{
variants: {
size: {
sm: "px-1.5 [&_svg:not([class*='size-'])]:size-3.5",
default:
"px-2 [&_svg:not([class*='size-'])]:size-4",
lg: "px-2.5 [&_svg:not([class*='size-'])]:size-4",
},
},
defaultVariants: {
size: "default",
},
}
)
const numberFieldInputVariants = cva(
"w-full min-w-0 flex-1 bg-transparent text-center tabular-nums outline-none",
{
variants: {
size: {
sm: "px-2 py-0.5",
default:
"px-2.5 py-1",
lg: "px-2.5 py-1.5",
},
},
defaultVariants: {
size: "default",
},
}
)
function NumberField({
id,
className,
size = "default",
...props
}: NumberFieldPrimitive.Root.Props &
VariantProps<typeof numberFieldGroupVariants>) {
const generatedId = useId()
const fieldId = id ?? generatedId
const sizeValue = size ?? "default"
return (
<NumberFieldContext.Provider value={{ fieldId, size: sizeValue }}>
<NumberFieldPrimitive.Root
className={cn("flex w-full flex-col items-start gap-2", className)}
data-size={sizeValue}
data-slot="number-field"
id={fieldId}
{...props}
/>
</NumberFieldContext.Provider>
)
}
function NumberFieldGroup({
className,
size: sizeProp,
...props
}: NumberFieldPrimitive.Group.Props &
Partial<VariantProps<typeof numberFieldGroupVariants>>) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldGroup must be used within a NumberField component."
)
}
const size = sizeProp ?? context.size
return (
<NumberFieldPrimitive.Group
className={cn(numberFieldGroupVariants({ size }), className)}
data-slot="number-field-group"
{...props}
/>
)
}
function NumberFieldDecrement({
className,
size: sizeProp,
children,
...props
}: NumberFieldPrimitive.Decrement.Props &
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
children?: React.ReactNode
}) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldDecrement must be used within a NumberField component."
)
}
const size = sizeProp ?? context.size
return (
<NumberFieldPrimitive.Decrement
className={cn(
numberFieldButtonVariants({ size }),
"rounded-s-lg border-e-0",
className
)}
data-slot="number-field-decrement"
{...props}
>
{children ?? (
<MinusIcon
/>
)}
</NumberFieldPrimitive.Decrement>
)
}
function NumberFieldIncrement({
className,
size: sizeProp,
children,
...props
}: NumberFieldPrimitive.Increment.Props &
Partial<VariantProps<typeof numberFieldButtonVariants>> & {
children?: ReactNode
}) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldIncrement must be used within a NumberField component."
)
}
const size = sizeProp ?? context.size
return (
<NumberFieldPrimitive.Increment
className={cn(
numberFieldButtonVariants({ size }),
"rounded-e-lg border-s-0",
className
)}
data-slot="number-field-increment"
{...props}
>
{children ?? (
<PlusIcon
/>
)}
</NumberFieldPrimitive.Increment>
)
}
function NumberFieldInput({
className,
size: sizeProp,
...props
}: NumberFieldPrimitive.Input.Props &
Partial<VariantProps<typeof numberFieldInputVariants>>) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldInput must be used within a NumberField component."
)
}
const size = sizeProp ?? context.size
return (
<NumberFieldPrimitive.Input
className={cn(numberFieldInputVariants({ size }), className)}
data-slot="number-field-input"
{...props}
/>
)
}
function NumberFieldScrubArea({
className,
label,
...props
}: NumberFieldPrimitive.ScrubArea.Props & {
label: string
}) {
const context = useContext(NumberFieldContext)
if (!context) {
throw new Error(
"NumberFieldScrubArea must be used within a NumberField component for accessibility."
)
}
return (
<NumberFieldPrimitive.ScrubArea
className={cn("flex cursor-ew-resize", className)}
data-slot="number-field-scrub-area"
{...props}
>
<Label className="cursor-ew-resize" htmlFor={context.fieldId}>
{label}
</Label>
<NumberFieldPrimitive.ScrubAreaCursor className="drop-shadow-[0_1px_1px_#0008] filter">
<CursorGrowIcon />
</NumberFieldPrimitive.ScrubAreaCursor>
</NumberFieldPrimitive.ScrubArea>
)
}
function CursorGrowIcon(props: React.ComponentProps<"svg">) {
return (
<svg
fill="black"
height="14"
stroke="white"
viewBox="0 0 24 14"
width="26"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M19.5 5.5L6.49737 5.51844V2L1 6.9999L6.5 12L6.49737 8.5L19.5 8.5V12L25 6.9999L19.5 2V5.5Z" />
</svg>
)
}
export {
NumberField,
NumberFieldScrubArea,
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldGroup,
NumberFieldInput,
}
+256
View File
@@ -0,0 +1,256 @@
import { createContext, useCallback, useContext, useState } from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cn } from "@telemt/ui/lib/utils"
// Types
type TimelineContextValue = {
activeStep: number
setActiveStep: (step: number) => void
}
// Context
const TimelineContext = createContext<TimelineContextValue | undefined>(
undefined
)
const useTimeline = () => {
const context = useContext(TimelineContext)
if (!context) {
throw new Error("useTimeline must be used within a Timeline")
}
return context
}
// Components
interface TimelineProps extends useRender.ComponentProps<"div"> {
defaultValue?: number
value?: number
onValueChange?: (value: number) => void
orientation?: "horizontal" | "vertical"
}
function Timeline({
defaultValue = 1,
value,
onValueChange,
orientation = "vertical",
className,
render,
children,
...props
}: TimelineProps) {
const [activeStep, setInternalStep] = useState(defaultValue)
const setActiveStep = useCallback(
(step: number) => {
if (value === undefined) {
setInternalStep(step)
}
onValueChange?.(step)
},
[value, onValueChange]
)
const currentStep = value ?? activeStep
const defaultProps = {
className: cn(
"group/timeline flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col",
className
),
"data-orientation": orientation,
"data-slot": "timeline",
children,
}
return (
<TimelineContext.Provider
value={{ activeStep: currentStep, setActiveStep }}
>
{useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})}
</TimelineContext.Provider>
)
}
// TimelineContent
function TimelineContent({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
className: cn("text-muted-foreground text-sm", className),
"data-slot": "timeline-content",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineDate
type TimelineDateProps = useRender.ComponentProps<"time">
function TimelineDate({
className,
render,
children,
...props
}: TimelineDateProps) {
const defaultProps = {
className: cn(
"mb-1 block font-medium text-muted-foreground text-xs group-data-[orientation=vertical]/timeline:max-sm:h-4",
className
),
"data-slot": "timeline-date",
children,
}
return useRender({
defaultTagName: "time",
render,
props: mergeProps<"time">(defaultProps, props),
})
}
// TimelineHeader
function TimelineHeader({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
className: cn(className),
"data-slot": "timeline-header",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineIndicator
type TimelineIndicatorProps = useRender.ComponentProps<"div">
function TimelineIndicator({
className,
children,
render,
...props
}: TimelineIndicatorProps) {
const defaultProps = {
"aria-hidden": true,
className: cn(
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute size-4 rounded-full border-2 border-primary/20 group-data-[orientation=vertical]/timeline:top-0 group-data-[orientation=horizontal]/timeline:left-0 group-data-completed/timeline-item:border-primary",
className
),
"data-slot": "timeline-indicator",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineItem
interface TimelineItemProps extends useRender.ComponentProps<"div"> {
step: number
}
function TimelineItem({
step,
className,
render,
children,
...props
}: TimelineItemProps) {
const { activeStep } = useTimeline()
const defaultProps = {
className: cn(
"group/timeline-item relative flex flex-1 flex-col gap-0.5 group-data-[orientation=vertical]/timeline:ms-8 group-data-[orientation=horizontal]/timeline:mt-8 group-data-[orientation=horizontal]/timeline:not-last:pe-8 group-data-[orientation=vertical]/timeline:not-last:pb-6 has-[+[data-completed]]:**:data-[slot=timeline-separator]:bg-primary",
className
),
"data-completed": step <= activeStep || undefined,
"data-slot": "timeline-item",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineSeparator
function TimelineSeparator({
className,
render,
children,
...props
}: useRender.ComponentProps<"div">) {
const defaultProps = {
"aria-hidden": true,
className: cn(
"group-data-[orientation=horizontal]/timeline:-top-6 group-data-[orientation=horizontal]/timeline:-translate-y-1/2 group-data-[orientation=vertical]/timeline:-left-6 group-data-[orientation=vertical]/timeline:-translate-x-1/2 absolute self-start bg-primary/10 group-last/timeline-item:hidden group-data-[orientation=horizontal]/timeline:h-0.5 group-data-[orientation=vertical]/timeline:h-[calc(100%-1rem-0.25rem)] group-data-[orientation=horizontal]/timeline:w-[calc(100%-1rem-0.25rem)] group-data-[orientation=vertical]/timeline:w-0.5 group-data-[orientation=horizontal]/timeline:translate-x-4.5 group-data-[orientation=vertical]/timeline:translate-y-4.5",
className
),
"data-slot": "timeline-separator",
children,
}
return useRender({
defaultTagName: "div",
render,
props: mergeProps<"div">(defaultProps, props),
})
}
// TimelineTitle
function TimelineTitle({
className,
render,
children,
...props
}: useRender.ComponentProps<"h3">) {
const defaultProps = {
className: cn("font-medium text-sm", className),
"data-slot": "timeline-title",
children,
}
return useRender({
defaultTagName: "h3",
render,
props: mergeProps<"h3">(defaultProps, props),
})
}
export {
Timeline,
TimelineContent,
TimelineDate,
TimelineHeader,
TimelineIndicator,
TimelineItem,
TimelineSeparator,
TimelineTitle,
}
@@ -0,0 +1,15 @@
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import type { ReactNode } from 'react'
export function ThemeProvider({ children }: { children: ReactNode }) {
return (
<NextThemesProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</NextThemesProvider>
)
}
@@ -0,0 +1,9 @@
import type { SVGProps } from "react";
const Apple = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} xmlSpace="preserve" viewBox="0 0 814 1000">
<path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z" />
</svg>
);
export { Apple };
@@ -0,0 +1,12 @@
import type { SVGProps } from "react";
const AppleDark = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} xmlSpace="preserve" viewBox="0 0 814 1000">
<path
fill="#fff"
d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"
/>
</svg>
);
export { AppleDark };
@@ -0,0 +1,15 @@
import type { SVGProps } from "react";
const GithubDark = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 1024 1024" fill="none">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M8 0C3.58 0 0 3.58 0 8C0 11.54 2.29 14.53 5.47 15.59C5.87 15.66 6.02 15.42 6.02 15.21C6.02 15.02 6.01 14.39 6.01 13.72C4 14.09 3.48 13.23 3.32 12.78C3.23 12.55 2.84 11.84 2.5 11.65C2.22 11.5 1.82 11.13 2.49 11.12C3.12 11.11 3.57 11.7 3.72 11.94C4.44 13.15 5.59 12.81 6.05 12.6C6.12 12.08 6.33 11.73 6.56 11.53C4.78 11.33 2.92 10.64 2.92 7.58C2.92 6.71 3.23 5.99 3.74 5.43C3.66 5.23 3.38 4.41 3.82 3.31C3.82 3.31 4.49 3.1 6.02 4.13C6.66 3.95 7.34 3.86 8.02 3.86C8.7 3.86 9.38 3.95 10.02 4.13C11.55 3.09 12.22 3.31 12.22 3.31C12.66 4.41 12.38 5.23 12.3 5.43C12.81 5.99 13.12 6.7 13.12 7.58C13.12 10.65 11.25 11.33 9.47 11.53C9.76 11.78 10.01 12.26 10.01 13.01C10.01 14.08 10 14.94 10 15.21C10 15.42 10.15 15.67 10.55 15.59C13.71 14.53 16 11.53 16 8C16 3.58 12.42 0 8 0Z"
transform="scale(64)"
fill="#ffff"
/>
</svg>
);
export { GithubDark };
@@ -0,0 +1,14 @@
import type { SVGProps } from "react";
const GithubLight = (props: SVGProps<SVGSVGElement>) => (
<svg {...props} viewBox="0 0 1024 1024" fill="none">
<path
fill="#1b1f23"
fillRule="evenodd"
d="M512 0C229.12 0 0 229.12 0 512c0 226.56 146.56 417.92 350.08 485.76 25.6 4.48 35.2-10.88 35.2-24.32 0-12.16-.64-52.48-.64-95.36-128.64 23.68-161.92-31.36-172.16-60.16-5.76-14.72-30.72-60.16-52.48-72.32-17.92-9.6-43.52-33.28-.64-33.92 40.32-.64 69.12 37.12 78.72 52.48 46.08 77.44 119.68 55.68 149.12 42.24 4.48-33.28 17.92-55.68 32.64-68.48-113.92-12.8-232.96-56.96-232.96-252.8 0-55.68 19.84-101.76 52.48-137.6-5.12-12.8-23.04-65.28 5.12-135.68 0 0 42.88-13.44 140.8 52.48 40.96-11.52 84.48-17.28 128-17.28s87.04 5.76 128 17.28c97.92-66.56 140.8-52.48 140.8-52.48 28.16 70.4 10.24 122.88 5.12 135.68 32.64 35.84 52.48 81.28 52.48 137.6 0 196.48-119.68 240-233.6 252.8 18.56 16 34.56 46.72 34.56 94.72 0 68.48-.64 123.52-.64 140.8 0 13.44 9.6 29.44 35.2 24.32C877.44 929.92 1024 737.92 1024 512 1024 229.12 794.88 0 512 0"
clipRule="evenodd"
/>
</svg>
);
export { GithubLight };
+243
View File
@@ -0,0 +1,243 @@
import type { SVGProps } from "react";
const Google = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
xmlnsXlink="http://www.w3.org/1999/xlink"
xmlSpace="preserve"
overflow="hidden"
viewBox="0 0 268.152 273.883"
>
<defs>
<linearGradient id="a">
<stop offset="0" stopColor="#0fbc5c" />
<stop offset="1" stopColor="#0cba65" />
</linearGradient>
<linearGradient id="g">
<stop offset=".231" stopColor="#0fbc5f" />
<stop offset=".312" stopColor="#0fbc5f" />
<stop offset=".366" stopColor="#0fbc5e" />
<stop offset=".458" stopColor="#0fbc5d" />
<stop offset=".54" stopColor="#12bc58" />
<stop offset=".699" stopColor="#28bf3c" />
<stop offset=".771" stopColor="#38c02b" />
<stop offset=".861" stopColor="#52c218" />
<stop offset=".915" stopColor="#67c30f" />
<stop offset="1" stopColor="#86c504" />
</linearGradient>
<linearGradient id="h">
<stop offset=".142" stopColor="#1abd4d" />
<stop offset=".248" stopColor="#6ec30d" />
<stop offset=".312" stopColor="#8ac502" />
<stop offset=".366" stopColor="#a2c600" />
<stop offset=".446" stopColor="#c8c903" />
<stop offset=".54" stopColor="#ebcb03" />
<stop offset=".616" stopColor="#f7cd07" />
<stop offset=".699" stopColor="#fdcd04" />
<stop offset=".771" stopColor="#fdce05" />
<stop offset=".861" stopColor="#ffce0a" />
</linearGradient>
<linearGradient id="f">
<stop offset=".316" stopColor="#ff4c3c" />
<stop offset=".604" stopColor="#ff692c" />
<stop offset=".727" stopColor="#ff7825" />
<stop offset=".885" stopColor="#ff8d1b" />
<stop offset="1" stopColor="#ff9f13" />
</linearGradient>
<linearGradient id="b">
<stop offset=".231" stopColor="#ff4541" />
<stop offset=".312" stopColor="#ff4540" />
<stop offset=".458" stopColor="#ff4640" />
<stop offset=".54" stopColor="#ff473f" />
<stop offset=".699" stopColor="#ff5138" />
<stop offset=".771" stopColor="#ff5b33" />
<stop offset=".861" stopColor="#ff6c29" />
<stop offset="1" stopColor="#ff8c18" />
</linearGradient>
<linearGradient id="d">
<stop offset=".408" stopColor="#fb4e5a" />
<stop offset="1" stopColor="#ff4540" />
</linearGradient>
<linearGradient id="c">
<stop offset=".132" stopColor="#0cba65" />
<stop offset=".21" stopColor="#0bb86d" />
<stop offset=".297" stopColor="#09b479" />
<stop offset=".396" stopColor="#08ad93" />
<stop offset=".477" stopColor="#0aa6a9" />
<stop offset=".568" stopColor="#0d9cc6" />
<stop offset=".667" stopColor="#1893dd" />
<stop offset=".769" stopColor="#258bf1" />
<stop offset=".859" stopColor="#3086ff" />
</linearGradient>
<linearGradient id="e">
<stop offset=".366" stopColor="#ff4e3a" />
<stop offset=".458" stopColor="#ff8a1b" />
<stop offset=".54" stopColor="#ffa312" />
<stop offset=".616" stopColor="#ffb60c" />
<stop offset=".771" stopColor="#ffcd0a" />
<stop offset=".861" stopColor="#fecf0a" />
<stop offset=".915" stopColor="#fecf08" />
<stop offset="1" stopColor="#fdcd01" />
</linearGradient>
<linearGradient
xlinkHref="#a"
id="s"
x1="219.7"
x2="254.467"
y1="329.535"
y2="329.535"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#b"
id="m"
cx="109.627"
cy="135.862"
r="71.46"
fx="109.627"
fy="135.862"
gradientTransform="matrix(-1.93688 1.043 1.45573 2.55542 290.525 -400.634)"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#c"
id="n"
cx="45.259"
cy="279.274"
r="71.46"
fx="45.259"
fy="279.274"
gradientTransform="matrix(-3.5126 -4.45809 -1.69255 1.26062 870.8 191.554)"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#d"
id="l"
cx="304.017"
cy="118.009"
r="47.854"
fx="304.017"
fy="118.009"
gradientTransform="matrix(2.06435 0 0 2.59204 -297.679 -151.747)"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#e"
id="o"
cx="181.001"
cy="177.201"
r="71.46"
fx="181.001"
fy="177.201"
gradientTransform="matrix(-.24858 2.08314 2.96249 .33417 -255.146 -331.164)"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#f"
id="p"
cx="207.673"
cy="108.097"
r="41.102"
fx="207.673"
fy="108.097"
gradientTransform="matrix(-1.2492 1.34326 -3.89684 -3.4257 880.501 194.905)"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#g"
id="r"
cx="109.627"
cy="135.862"
r="71.46"
fx="109.627"
fy="135.862"
gradientTransform="matrix(-1.93688 -1.043 1.45573 -2.55542 290.525 838.683)"
gradientUnits="userSpaceOnUse"
/>
<radialGradient
xlinkHref="#h"
id="j"
cx="154.87"
cy="145.969"
r="71.46"
fx="154.87"
fy="145.969"
gradientTransform="matrix(-.0814 -1.93722 2.92674 -.11625 -215.135 632.86)"
gradientUnits="userSpaceOnUse"
/>
<filter
id="q"
width="1.097"
height="1.116"
x="-.048"
y="-.058"
colorInterpolationFilters="sRGB"
>
<feGaussianBlur stdDeviation="1.701" />
</filter>
<filter
id="k"
width="1.033"
height="1.02"
x="-.017"
y="-.01"
colorInterpolationFilters="sRGB"
>
<feGaussianBlur stdDeviation=".242" />
</filter>
<clipPath id="i" clipPathUnits="userSpaceOnUse">
<path d="M371.378 193.24H237.083v53.438h77.167c-1.241 7.563-4.026 15.003-8.105 21.786-4.674 7.773-10.451 13.69-16.373 18.196-17.74 13.498-38.42 16.258-52.783 16.258-36.283 0-67.283-23.286-79.285-54.928-.484-1.149-.805-2.335-1.197-3.507a81.115 81.115 0 0 1-4.101-25.448c0-9.226 1.569-18.057 4.43-26.398 11.285-32.897 42.985-57.467 80.179-57.467 7.481 0 14.685.884 21.517 2.648a77.668 77.668 0 0 1 33.425 18.25l40.834-39.712c-24.839-22.616-57.219-36.32-95.844-36.32-30.878 0-59.386 9.553-82.748 25.7-18.945 13.093-34.483 30.625-44.97 50.985-9.753 18.879-15.094 39.8-15.094 62.294 0 22.495 5.35 43.633 15.103 62.337v.126c10.302 19.857 25.368 36.954 43.678 49.988 15.997 11.386 44.68 26.551 84.031 26.551 22.63 0 42.687-4.051 60.375-11.644 12.76-5.478 24.065-12.622 34.301-21.804 13.525-12.132 24.117-27.139 31.347-44.404 7.23-17.265 11.097-36.79 11.097-57.957 0-9.858-.998-19.87-2.689-28.968Z" />
</clipPath>
</defs>
<g clipPath="url(#i)" transform="matrix(.95792 0 0 .98525 -90.174 -78.856)">
<path
fill="url(#j)"
d="M92.076 219.958c.148 22.14 6.501 44.983 16.117 63.424v.127c6.949 13.392 16.445 23.97 27.26 34.452l65.327-23.67c-12.36-6.235-14.246-10.055-23.105-17.026-9.054-9.066-15.802-19.473-20.004-31.677h-.17l.17-.127c-2.765-8.058-3.037-16.613-3.14-25.503Z"
filter="url(#k)"
/>
<path
fill="url(#l)"
d="M237.083 79.025c-6.456 22.526-3.988 44.421 0 57.161 7.457.006 14.64.888 21.45 2.647a77.662 77.662 0 0 1 33.424 18.25l41.88-40.726c-24.81-22.59-54.667-37.297-96.754-37.332Z"
filter="url(#k)"
/>
<path
fill="url(#m)"
d="M236.943 78.847c-31.67 0-60.91 9.798-84.871 26.359a145.533 145.533 0 0 0-24.332 21.15c-1.904 17.744 14.257 39.551 46.262 39.37 15.528-17.936 38.495-29.542 64.056-29.542l.07.002-1.044-57.335c-.048 0-.093-.004-.14-.004Z"
filter="url(#k)"
/>
<path
fill="url(#n)"
d="m341.475 226.379-28.268 19.285c-1.24 7.562-4.028 15.002-8.107 21.786-4.674 7.772-10.45 13.69-16.373 18.196-17.702 13.47-38.328 16.244-52.687 16.255-14.842 25.102-17.444 37.675 1.043 57.934 22.877-.016 43.157-4.117 61.046-11.796 12.931-5.551 24.388-12.792 34.761-22.097 13.706-12.295 24.442-27.503 31.769-45 7.327-17.497 11.245-37.282 11.245-58.734Z"
filter="url(#k)"
/>
<path
fill="#3086ff"
d="M234.996 191.21v57.498h136.006c1.196-7.874 5.152-18.064 5.152-26.5 0-9.858-.996-21.899-2.687-30.998Z"
filter="url(#k)"
/>
<path
fill="url(#o)"
d="M128.39 124.327c-8.394 9.119-15.564 19.326-21.249 30.364-9.753 18.879-15.094 41.83-15.094 64.324 0 .317.026.627.029.944 4.32 8.224 59.666 6.649 62.456 0-.004-.31-.039-.613-.039-.924 0-9.226 1.57-16.026 4.43-24.367 3.53-10.289 9.056-19.763 16.123-27.926 1.602-2.031 5.875-6.397 7.121-9.016.475-.997-.862-1.557-.937-1.908-.083-.393-1.876-.077-2.277-.37-1.275-.929-3.8-1.414-5.334-1.845-3.277-.921-8.708-2.953-11.725-5.06-9.536-6.658-24.417-14.612-33.505-24.216Z"
filter="url(#k)"
/>
<path
fill="url(#p)"
d="M162.099 155.857c22.112 13.301 28.471-6.714 43.173-12.977l-25.574-52.664a144.74 144.74 0 0 0-26.543 14.504c-12.316 8.512-23.192 18.9-32.176 30.72Z"
filter="url(#q)"
/>
<path
fill="url(#r)"
d="M171.099 290.222c-29.683 10.641-34.33 11.023-37.062 29.29a144.806 144.806 0 0 0 16.792 13.984c15.996 11.386 46.766 26.551 86.118 26.551.046 0 .09-.004.137-.004v-59.157l-.094.002c-14.736 0-26.512-3.843-38.585-10.527-2.977-1.648-8.378 2.777-11.123.799-3.786-2.729-12.9 2.35-16.183-.938Z"
filter="url(#k)"
/>
<path
fill="url(#s)"
d="M219.7 299.023v59.996c5.506.64 11.236 1.028 17.247 1.028 6.026 0 11.855-.307 17.52-.872v-59.748a105.119 105.119 0 0 1-17.477 1.461c-5.932 0-11.7-.686-17.29-1.865Z"
filter="url(#k)"
opacity=".5"
/>
</g>
</svg>
);
export { Google };
+53
View File
@@ -0,0 +1,53 @@
import { clearToken, getToken } from '@/lib/auth'
export class ApiError extends Error {
constructor(
public status: number,
public code: string,
message: string,
) {
super(message)
}
}
export async function api<T = unknown>(
path: string,
init: RequestInit = {},
): Promise<T> {
const headers = new Headers(init.headers)
const token = getToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
if (init.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
const res = await fetch(path, { ...init, headers, credentials: 'include' })
if (res.status === 401) {
clearToken()
if (!window.location.pathname.startsWith('/login')) {
window.location.assign('/login')
}
throw new ApiError(401, 'unauthorized', 'Требуется вход')
}
const text = await res.text()
let data: unknown = null
if (text) {
try {
data = JSON.parse(text)
} catch {
data = text
}
}
if (!res.ok) {
const err = data as { error?: { code?: string; message?: string } } | null
throw new ApiError(
res.status,
err?.error?.code ?? 'error',
err?.error?.message ?? res.statusText,
)
}
return data as T
}
+17
View File
@@ -0,0 +1,17 @@
const TOKEN_KEY = 'telemt_panel_token'
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY)
}
export function isLoggedIn(): boolean {
return Boolean(getToken())
}
+11
View File
@@ -0,0 +1,11 @@
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
})
+25
View File
@@ -0,0 +1,25 @@
import { QueryClient } from '@tanstack/react-query'
import {
createRouter as tanstackCreateRouter,
rootRouteId,
} from '@tanstack/react-router'
import { routeTree } from '../routeTree.gen'
import { queryClient } from './queryClient'
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof createRouter>
}
}
export function createRouter(opts?: { context?: { queryClient: QueryClient } }) {
return tanstackCreateRouter({
routeTree,
context: opts?.context ?? { queryClient },
defaultPreload: 'intent',
scrollRestoration: true,
})
}
export { rootRouteId }
+14
View File
@@ -0,0 +1,14 @@
/**
* Telemt Panel UI surface contract (ReUI Pro).
*
* Ops / list / dashboard / detail / settings: **Frame** only.
* KPI: horizontal compact hybrid via `reui-kit/KpiStatGrid`.
*
* @see https://reui.io/preview/base/stats-12
* @see https://reui.io/preview/base/app-shell-12
* @see docs/ui-design-contract.md
*/
export const UI_SURFACE = 'frame' as const
/** Grid for horizontal compact hybrid KPI rows (36 tiles). */
export const kpiStatGridClassName = '@container w-full'
+27
View File
@@ -0,0 +1,27 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClientProvider } from '@tanstack/react-query'
import { RouterProvider } from '@tanstack/react-router'
import { Toaster } from '@telemt/ui/components/sonner'
import '@telemt/ui/globals.css'
import { queryClient } from '@/lib/queryClient'
import { createRouter } from '@/lib/router'
import { ThemeProvider } from '@/components/theme-provider'
const router = createRouter({ context: { queryClient } })
const rootEl = document.getElementById('root')
if (!rootEl) throw new Error('Root element #root not found')
createRoot(rootEl).render(
<StrictMode>
<ThemeProvider>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster richColors position="top-right" />
</QueryClientProvider>
</ThemeProvider>
</StrictMode>,
)
+231
View File
@@ -0,0 +1,231 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ClientsRouteImport } from './routes/clients'
import { Route as DashboardRouteImport } from './routes/dashboard'
import { Route as LoginRouteImport } from './routes/login'
import { Route as RuntimeRouteImport } from './routes/runtime'
import { Route as SecurityRouteImport } from './routes/security'
import { Route as ServersRouteImport } from './routes/servers'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as UsersRouteImport } from './routes/users'
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const ClientsRoute = ClientsRouteImport.update({
id: '/clients',
path: '/clients',
getParentRoute: () => rootRouteImport,
} as any)
const DashboardRoute = DashboardRouteImport.update({
id: '/dashboard',
path: '/dashboard',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const RuntimeRoute = RuntimeRouteImport.update({
id: '/runtime',
path: '/runtime',
getParentRoute: () => rootRouteImport,
} as any)
const SecurityRoute = SecurityRouteImport.update({
id: '/security',
path: '/security',
getParentRoute: () => rootRouteImport,
} as any)
const ServersRoute = ServersRouteImport.update({
id: '/servers',
path: '/servers',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsRoute = SettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const UsersRoute = UsersRouteImport.update({
id: '/users',
path: '/users',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/clients': typeof ClientsRoute
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/runtime': typeof RuntimeRoute
'/security': typeof SecurityRoute
'/servers': typeof ServersRoute
'/settings': typeof SettingsRoute
'/users': typeof UsersRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/clients': typeof ClientsRoute
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/runtime': typeof RuntimeRoute
'/security': typeof SecurityRoute
'/servers': typeof ServersRoute
'/settings': typeof SettingsRoute
'/users': typeof UsersRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/clients': typeof ClientsRoute
'/dashboard': typeof DashboardRoute
'/login': typeof LoginRoute
'/runtime': typeof RuntimeRoute
'/security': typeof SecurityRoute
'/servers': typeof ServersRoute
'/settings': typeof SettingsRoute
'/users': typeof UsersRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/clients'
| '/dashboard'
| '/login'
| '/runtime'
| '/security'
| '/servers'
| '/settings'
| '/users'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/clients'
| '/dashboard'
| '/login'
| '/runtime'
| '/security'
| '/servers'
| '/settings'
| '/users'
id:
| '__root__'
| '/'
| '/clients'
| '/dashboard'
| '/login'
| '/runtime'
| '/security'
| '/servers'
| '/settings'
| '/users'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
ClientsRoute: typeof ClientsRoute
DashboardRoute: typeof DashboardRoute
LoginRoute: typeof LoginRoute
RuntimeRoute: typeof RuntimeRoute
SecurityRoute: typeof SecurityRoute
ServersRoute: typeof ServersRoute
SettingsRoute: typeof SettingsRoute
UsersRoute: typeof UsersRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/clients': {
id: '/clients'
path: '/clients'
fullPath: '/clients'
preLoaderRoute: typeof ClientsRouteImport
parentRoute: typeof rootRouteImport
}
'/dashboard': {
id: '/dashboard'
path: '/dashboard'
fullPath: '/dashboard'
preLoaderRoute: typeof DashboardRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/runtime': {
id: '/runtime'
path: '/runtime'
fullPath: '/runtime'
preLoaderRoute: typeof RuntimeRouteImport
parentRoute: typeof rootRouteImport
}
'/security': {
id: '/security'
path: '/security'
fullPath: '/security'
preLoaderRoute: typeof SecurityRouteImport
parentRoute: typeof rootRouteImport
}
'/servers': {
id: '/servers'
path: '/servers'
fullPath: '/servers'
preLoaderRoute: typeof ServersRouteImport
parentRoute: typeof rootRouteImport
}
'/settings': {
id: '/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/users': {
id: '/users'
path: '/users'
fullPath: '/users'
preLoaderRoute: typeof UsersRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ClientsRoute: ClientsRoute,
DashboardRoute: DashboardRoute,
LoginRoute: LoginRoute,
RuntimeRoute: RuntimeRoute,
SecurityRoute: SecurityRoute,
ServersRoute: ServersRoute,
SettingsRoute: SettingsRoute,
UsersRoute: UsersRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
+30
View File
@@ -0,0 +1,30 @@
import { Outlet, createRootRouteWithContext, redirect, useRouterState } from '@tanstack/react-router'
import { AppShell } from '@/components/layout/app-shell'
import { isLoggedIn } from '@/lib/auth'
interface RouterContext {
queryClient: import('@tanstack/react-query').QueryClient
}
export const Route = createRootRouteWithContext<RouterContext>()({
beforeLoad: ({ location }) => {
if (location.pathname === '/login') return
if (!isLoggedIn()) {
throw redirect({ to: '/login' })
}
},
component: RootComponent,
})
function RootComponent() {
const pathname = useRouterState({ select: (s) => s.location.pathname })
if (pathname === '/login') {
return <Outlet />
}
return (
<AppShell>
<Outlet />
</AppShell>
)
}
+135
View File
@@ -0,0 +1,135 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { UsersIcon } from 'lucide-react'
import { useState, type FormEvent } from 'react'
import {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
} from '@/components/reui/frame'
import { PageHeader } from '@/components/page-header'
import { EmptyState } from '@/components/empty-state'
import { Button } from '@telemt/ui/components/button'
import { Input } from '@telemt/ui/components/input'
import { api } from '@/lib/api-client'
/** Managed clients — fleet catalog; @see https://reui.io/preview/base/form-7 */
export const Route = createFileRoute('/clients')({
component: ClientsPage,
})
interface ClientRow {
id: string
username: string
createdAt: string
}
function ClientsPage() {
const qc = useQueryClient()
const [username, setUsername] = useState('')
const [secret, setSecret] = useState('')
const clients = useQuery({
queryKey: ['clients'],
queryFn: () => api<ClientRow[]>('/api/clients'),
})
const create = useMutation({
mutationFn: () =>
api('/api/clients', {
method: 'POST',
body: JSON.stringify({
username,
secret: secret || undefined,
}),
}),
onSuccess: () => {
setUsername('')
setSecret('')
void qc.invalidateQueries({ queryKey: ['clients'] })
},
})
function handleSubmit(e: FormEvent) {
e.preventDefault()
create.mutate()
}
const rows = clients.data ?? []
return (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Клиенты"
description="Managed clients каталог (fleet) / sync в Telemt (standalone)."
/>
<Frame>
<FrameHeader>
<FrameTitle>Создать</FrameTitle>
<FrameDescription>
В standalone сразу POST /v1/users. Preview: form-7.
</FrameDescription>
</FrameHeader>
<FramePanel>
<form onSubmit={handleSubmit} className="flex flex-wrap items-end gap-3">
<div className="flex min-w-40 flex-1 flex-col gap-1.5">
<label className="text-muted-foreground text-xs" htmlFor="client-user">
Username
</label>
<Input
id="client-user"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</div>
<div className="flex min-w-40 flex-1 flex-col gap-1.5">
<label className="text-muted-foreground text-xs" htmlFor="client-secret">
Secret (опц.)
</label>
<Input
id="client-secret"
value={secret}
onChange={(e) => setSecret(e.target.value)}
/>
</div>
<Button type="submit" disabled={create.isPending || !username}>
Создать
</Button>
</form>
{create.isError ? (
<p className="text-destructive mt-3 text-sm">{create.error.message}</p>
) : null}
</FramePanel>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Каталог</FrameTitle>
<FrameDescription>Локальная таблица managed_clients.</FrameDescription>
</FrameHeader>
<FramePanel>
{rows.length === 0 ? (
<EmptyState
icon={UsersIcon}
title="Пока нет клиентов"
description="Создайте запись выше."
centered={false}
/>
) : (
<ul className="flex flex-col gap-2 text-sm">
{rows.map((c) => (
<li key={c.id} className="flex items-center justify-between border-b py-2">
<span className="font-medium">{c.username}</span>
<span className="text-muted-foreground text-xs">{c.createdAt}</span>
</li>
))}
</ul>
)}
</FramePanel>
</Frame>
</div>
)
}
+91
View File
@@ -0,0 +1,91 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { ActivityIcon, ServerIcon, UsersIcon } from 'lucide-react'
import {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
} from '@/components/reui/frame'
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
import { PageHeader } from '@/components/page-header'
import { UI_SURFACE } from '@/lib/ui-surface'
import { api } from '@/lib/api-client'
/** @see https://reui.io/preview/base/stats-12 */
export const Route = createFileRoute('/dashboard')({
component: DashboardPage,
})
function DashboardPage() {
const summary = useQuery({
queryKey: ['telemt', 'summary'],
queryFn: () =>
api<{ ok?: boolean; data?: Record<string, unknown> }>('/api/telemt/stats/summary').catch(
() => null,
),
refetchInterval: 10_000,
})
const servers = useQuery({
queryKey: ['servers'],
queryFn: () =>
api<Array<{ id: string; status: string }>>('/api/servers').catch(() => []),
})
const data = summary.data?.data ?? {}
const online = (servers.data ?? []).filter((s) => s.status === 'online').length
const items: KpiStatItem[] = [
{
id: 'servers',
label: 'Серверы',
value: String(servers.data?.length ?? '—'),
hint: `${online} online`,
icon: <ServerIcon className="size-5 text-primary" />,
},
{
id: 'conns',
label: 'Соединения',
value: String(data.connections ?? data.total_connections ?? '—'),
hint: 'Telemt summary',
icon: <ActivityIcon className="size-5 text-primary" />,
},
{
id: 'users',
label: 'Users',
value: String(data.users ?? data.user_count ?? '—'),
hint: 'from /v1/stats/summary',
icon: <UsersIcon className="size-5 text-primary" />,
},
]
return (
<div className="flex flex-col gap-4 md:gap-6" data-surface={UI_SURFACE}>
<PageHeader
title="Дашборд"
description="Telemt Panel — KPI hybrid (stats-12)."
/>
<KpiStatGrid items={items} />
<Frame>
<FrameHeader>
<FrameTitle>Статус API</FrameTitle>
<FrameDescription>
{summary.isError
? 'Telemt недоступен — проверьте TELEMT_API_URL / whitelist'
: summary.isLoading
? 'Загрузка…'
: 'Данные с Control API /v1'}
</FrameDescription>
</FrameHeader>
<FramePanel>
<pre className="text-muted-foreground overflow-auto text-xs">
{JSON.stringify(summary.data ?? { pending: true }, null, 2)}
</pre>
</FramePanel>
</Frame>
</div>
)
}
+7
View File
@@ -0,0 +1,7 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/')({
beforeLoad: () => {
throw redirect({ to: '/dashboard' })
},
})
+111
View File
@@ -0,0 +1,111 @@
import { createFileRoute, redirect, useNavigate } from '@tanstack/react-router'
import { useState, type FormEvent } from 'react'
import { api } from '@/lib/api-client'
import { isLoggedIn, setToken } from '@/lib/auth'
import { Button } from '@telemt/ui/components/button'
import { Field, FieldGroup, FieldLabel } from '@telemt/ui/components/field'
import { Input } from '@telemt/ui/components/input'
import {
Frame,
FrameDescription,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
/**
* Local login — ReUI PRO auth-4 DNA, wired to panel JWT.
* @see https://reui.io/preview/base/auth-4
* @see https://reui.io/docs/blocks
*/
export const Route = createFileRoute('/login')({
beforeLoad: () => {
if (isLoggedIn()) throw redirect({ to: '/dashboard' })
},
component: LoginPage,
})
function LoginPage() {
const navigate = useNavigate()
const [username, setUsername] = useState('admin')
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setPending(true)
setError(null)
try {
const res = await api<{ accessToken: string }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
})
setToken(res.accessToken)
await navigate({ to: '/dashboard' })
} catch (err) {
setError(err instanceof Error ? err.message : 'Ошибка входа')
} finally {
setPending(false)
}
}
return (
<div className="bg-background relative flex min-h-svh flex-col items-center justify-center p-6">
<div className="w-full max-w-md">
<Frame className="w-full">
<FramePanel className="flex flex-col gap-6 p-6">
<div className="flex flex-col gap-1 text-center">
<FrameTitle className="text-xl">Telemt Panel</FrameTitle>
<FrameDescription>Локальный вход оператора</FrameDescription>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<FieldGroup>
<Field>
<FieldLabel htmlFor="username">Логин</FieldLabel>
<Input
id="username"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
</Field>
<Field>
<FieldLabel htmlFor="password">Пароль</FieldLabel>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</Field>
</FieldGroup>
{error ? (
<p className="text-destructive text-sm" role="alert">
{error}
</p>
) : null}
<Button type="submit" disabled={pending} className="w-full">
{pending ? 'Вход…' : 'Войти'}
</Button>
</form>
<p className="text-muted-foreground text-center text-xs">
UI: ReUI auth-4 ·{' '}
<a
className="underline"
href="https://reui.io/preview/base/auth-4"
target="_blank"
rel="noreferrer"
>
preview
</a>
</p>
</FramePanel>
</Frame>
</div>
</div>
)
}
+56
View File
@@ -0,0 +1,56 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
} from '@/components/reui/frame'
import { PageHeader } from '@/components/page-header'
import { api } from '@/lib/api-client'
/** @see https://reui.io/docs/components/base/frame */
export const Route = createFileRoute('/runtime')({
component: RuntimePage,
})
function RuntimePage() {
const mePool = useQuery({
queryKey: ['telemt', 'me_pool'],
queryFn: () => api('/api/telemt/runtime/me_pool_state').catch(() => null),
})
const events = useQuery({
queryKey: ['telemt', 'events'],
queryFn: () => api('/api/telemt/runtime/events/recent').catch(() => null),
})
return (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader title="Runtime" description="ME pool / events — Telemt runtime endpoints." />
<Frame>
<FrameHeader>
<FrameTitle>ME pool state</FrameTitle>
<FrameDescription>/v1/runtime/me_pool_state</FrameDescription>
</FrameHeader>
<FramePanel>
<pre className="text-muted-foreground overflow-auto text-xs">
{JSON.stringify(mePool.data ?? { pending: mePool.isLoading }, null, 2)}
</pre>
</FramePanel>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Recent events</FrameTitle>
<FrameDescription>/v1/runtime/events/recent</FrameDescription>
</FrameHeader>
<FramePanel>
<pre className="text-muted-foreground overflow-auto text-xs">
{JSON.stringify(events.data ?? { pending: events.isLoading }, null, 2)}
</pre>
</FramePanel>
</Frame>
</div>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
} from '@/components/reui/frame'
import { PageHeader } from '@/components/page-header'
import { api } from '@/lib/api-client'
/** @see https://reui.io/docs/components/base/frame */
export const Route = createFileRoute('/security')({
component: SecurityPage,
})
function SecurityPage() {
const posture = useQuery({
queryKey: ['telemt', 'security'],
queryFn: () => api('/api/telemt/security/posture').catch(() => null),
})
return (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Безопасность"
description="Telemt /v1/security/* через BFF (standalone) или agent proxy (fleet)."
/>
<Frame>
<FrameHeader>
<FrameTitle>Security posture</FrameTitle>
<FrameDescription>/v1/security/posture</FrameDescription>
</FrameHeader>
<FramePanel>
<pre className="text-muted-foreground overflow-auto text-xs">
{JSON.stringify(posture.data ?? { pending: posture.isLoading }, null, 2)}
</pre>
</FramePanel>
</Frame>
</div>
)
}
+153
View File
@@ -0,0 +1,153 @@
import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ServerIcon } from 'lucide-react'
import { useState, type FormEvent } from 'react'
import {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
} from '@/components/reui/frame'
import { PageHeader } from '@/components/page-header'
import { EmptyState } from '@/components/empty-state'
import { Button } from '@telemt/ui/components/button'
import { Input } from '@telemt/ui/components/input'
import { Badge } from '@/components/reui/badge'
import { api } from '@/lib/api-client'
/** Fleet servers — @see https://reui.io/preview/base/empty-state-12 */
export const Route = createFileRoute('/servers')({
component: ServersPage,
})
interface ServerRow {
id: string
name: string
status: string
hostname?: string | null
lastSeenAt?: string | null
agentVersion?: string | null
mode?: string
}
function ServersPage() {
const qc = useQueryClient()
const [label, setLabel] = useState('node')
const [installCmd, setInstallCmd] = useState<string | null>(null)
const servers = useQuery({
queryKey: ['servers'],
queryFn: () => api<ServerRow[]>('/api/servers'),
refetchInterval: 5_000,
})
const enroll = useMutation({
mutationFn: () =>
api<{ token: string; installCommand: string; expiresAt: string }>(
'/api/enrollment-tokens',
{ method: 'POST', body: JSON.stringify({ label, ttlHours: 24 }) },
),
onSuccess: (data) => {
setInstallCmd(data.installCommand)
void qc.invalidateQueries({ queryKey: ['servers'] })
},
})
function handleEnroll(e: FormEvent) {
e.preventDefault()
enroll.mutate()
}
const rows = servers.data ?? []
return (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Серверы"
description="Флот агентов (PANEL_MODE=fleet). Preview: empty-state-12 / ResourcePage."
/>
<Frame>
<FrameHeader>
<FrameTitle>Enrollment</FrameTitle>
<FrameDescription>
Создайте токен и установите агент на ноде с Telemt.
</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-4">
<form onSubmit={handleEnroll} className="flex flex-wrap items-end gap-3">
<div className="flex min-w-48 flex-1 flex-col gap-1.5">
<label className="text-muted-foreground text-xs" htmlFor="enroll-label">
Метка
</label>
<Input
id="enroll-label"
value={label}
onChange={(e) => setLabel(e.target.value)}
/>
</div>
<Button type="submit" disabled={enroll.isPending}>
{enroll.isPending ? 'Создание…' : 'Создать токен'}
</Button>
</form>
{enroll.isError ? (
<p className="text-destructive text-sm">{enroll.error.message}</p>
) : null}
{installCmd ? (
<pre className="bg-muted overflow-x-auto rounded-md p-3 text-xs">{installCmd}</pre>
) : null}
</FramePanel>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Список серверов</FrameTitle>
<FrameDescription>Агенты, подключённые к control plane.</FrameDescription>
</FrameHeader>
<FramePanel>
{servers.isLoading ? (
<p className="text-muted-foreground text-sm">Загрузка</p>
) : rows.length === 0 ? (
<EmptyState
icon={ServerIcon}
title="Пока нет серверов"
description="Создайте enrollment-токен и запустите install-agent.sh на ноде."
centered={false}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-2 pr-4">Имя</th>
<th className="py-2 pr-4">Статус</th>
<th className="py-2 pr-4">Hostname</th>
<th className="py-2">Last seen</th>
</tr>
</thead>
<tbody>
{rows.map((s) => (
<tr key={s.id} className="border-b border-border/50">
<td className="py-2 pr-4 font-medium">{s.name}</td>
<td className="py-2 pr-4">
<Badge variant="outline" size="sm">
{s.status}
</Badge>
</td>
<td className="py-2 pr-4">{s.hostname ?? '—'}</td>
<td className="py-2 text-muted-foreground text-xs">
{s.lastSeenAt ?? '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</FramePanel>
</Frame>
</div>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
} from '@/components/reui/frame'
import { PageHeader } from '@/components/page-header'
import { api } from '@/lib/api-client'
/** @see https://reui.io/preview/base/settings-16 · https://reui.io/docs/blocks */
export const Route = createFileRoute('/settings')({
component: SettingsPage,
})
function SettingsPage() {
const config = useQuery({
queryKey: ['auth-config'],
queryFn: () =>
api<{ panelMode: string; issuer: string }>('/api/auth/config'),
})
const health = useQuery({
queryKey: ['ready'],
queryFn: () =>
fetch('/ready')
.then((r) => r.json())
.catch(() => null),
})
return (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader
title="Настройки"
description="Панель и режим. Preview: settings-16."
/>
<Frame>
<FrameHeader>
<FrameTitle>Режим</FrameTitle>
<FrameDescription>PANEL_MODE и issuer (чтение из API).</FrameDescription>
</FrameHeader>
<FramePanel className="flex flex-col gap-2 text-sm">
<div className="flex gap-2">
<span className="text-muted-foreground">panelMode</span>
<span className="font-medium">{config.data?.panelMode ?? '…'}</span>
</div>
<div className="flex gap-2">
<span className="text-muted-foreground">issuer</span>
<span className="font-medium">{config.data?.issuer ?? '…'}</span>
</div>
</FramePanel>
</Frame>
<Frame>
<FrameHeader>
<FrameTitle>Ready</FrameTitle>
<FrameDescription>/ready SQLite + Telemt reachability</FrameDescription>
</FrameHeader>
<FramePanel>
<pre className="text-muted-foreground overflow-auto text-xs">
{JSON.stringify(health.data ?? { pending: true }, null, 2)}
</pre>
</FramePanel>
</Frame>
</div>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { UsersIcon } from 'lucide-react'
import {
Frame,
FramePanel,
FrameHeader,
FrameTitle,
FrameDescription,
} from '@/components/reui/frame'
import { PageHeader } from '@/components/page-header'
import { EmptyState } from '@/components/empty-state'
import { api } from '@/lib/api-client'
/** Users via Telemt /v1/users — @see https://reui.io/preview/base/data-grid-filtering-2 */
export const Route = createFileRoute('/users')({
component: UsersPage,
})
function UsersPage() {
const users = useQuery({
queryKey: ['telemt', 'users'],
queryFn: async () => {
const res = await api<{ ok?: boolean; data?: unknown }>('/api/telemt/users')
const data = res.data
return Array.isArray(data) ? data : []
},
})
const rows = (users.data ?? []) as Array<Record<string, unknown>>
return (
<div className="flex flex-col gap-4 md:gap-6">
<PageHeader title="Пользователи" description="Telemt /v1/users (standalone BFF)." />
<Frame>
<FrameHeader>
<FrameTitle>Список</FrameTitle>
<FrameDescription>
Preview: data-grid-filtering-2 kit ResourcePage в следующих итерациях.
</FrameDescription>
</FrameHeader>
<FramePanel>
{users.isLoading ? (
<p className="text-muted-foreground text-sm">Загрузка</p>
) : rows.length === 0 ? (
<EmptyState
icon={UsersIcon}
title="Нет пользователей"
description="Создайте пользователя через Telemt API или Clients."
centered={false}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-2 pr-4">username</th>
<th className="py-2 pr-4">active</th>
<th className="py-2">connections</th>
</tr>
</thead>
<tbody>
{rows.map((u, i) => (
<tr key={String(u.username ?? i)} className="border-b border-border/50">
<td className="py-2 pr-4 font-medium">{String(u.username ?? '—')}</td>
<td className="py-2 pr-4">{String(u.enabled ?? u.active ?? '—')}</td>
<td className="py-2 tabular-nums">
{String(u.connections ?? u.active_connections ?? '—')}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</FramePanel>
</Frame>
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL?: string
readonly VITE_APP_SWITCHER?: string
readonly VITE_AUTH_ENABLED?: string
readonly VITE_AUTH_PORTAL_URL?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
},
"include": ["src"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@telemt/ui/components/*": ["../../packages/ui/src/components/*"],
"@telemt/ui/hooks/*": ["../../packages/ui/src/hooks/*"],
"@telemt/ui/lib/utils": ["../../packages/ui/src/lib/utils.ts"],
"@telemt/ui/globals.css": ["../../packages/ui/src/styles/globals.css"],
"@telemt/shared": ["../../packages/shared/src/index.ts"]
},
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["vite.config.ts"]
}
+59
View File
@@ -0,0 +1,59 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import { TanStackRouterVite as TanStackRouterPlugin } from '@tanstack/router-plugin/vite'
import path from 'node:path'
export default defineConfig({
plugins: [
TanStackRouterPlugin({ target: 'react', autoCodeSplitting: true }),
react(),
tailwindcss(),
],
resolve: {
alias: [
{ find: '@', replacement: path.resolve(__dirname, './src') },
{
find: '@telemt/ui/components',
replacement: path.resolve(__dirname, '../../packages/ui/src/components'),
},
{
find: '@telemt/ui/hooks',
replacement: path.resolve(__dirname, '../../packages/ui/src/hooks'),
},
{
find: '@telemt/ui/lib/utils',
replacement: path.resolve(__dirname, '../../packages/ui/src/lib/utils.ts'),
},
{
find: '@telemt/shared',
replacement: path.resolve(__dirname, '../../packages/shared/src/index.ts'),
},
],
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
'/health': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
'/ready': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
'/install-agent.sh': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
},
},
test: {
environment: 'happy-dom',
globals: true,
},
})