Merge branch 'dev' into fix/delete-cascade

This commit is contained in:
Francis Cao
2026-05-07 13:58:29 -07:00
committed by GitHub
112 changed files with 772 additions and 272 deletions
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "مُجمّعة",
"filter-raw": "خام",
"filters": "التصفيات",
"filters-enabled": "الفلاتر مفعّلة",
"first-click": "النقرة الأولى",
"first-seen": "أول ظهور",
"funnel": "قمع",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "Камбініраваны",
"filter-raw": "Сырыя",
"filters": "Фільтры",
"filters-enabled": "Фільтры ўключаны",
"first-click": "Першы клік",
"first-seen": "Першы раз убачана",
"funnel": "Варонка",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "Комбиниран",
"filter-raw": "Суров",
"filters": "Филтри",
"filters-enabled": "Филтрите са активирани",
"first-click": "Първо кликване",
"first-seen": "Първо видяно",
"funnel": "Фуния",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "সম্মিলিত",
"filter-raw": "অপরিশোধিত",
"filters": "ফিল্টারসমূহ",
"filters-enabled": "ফিল্টার সক্রিয়",
"first-click": "প্রথম ক্লিক",
"first-seen": "প্রথম দেখা",
"funnel": "ফানেল",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Kombinovano",
"filter-raw": "Sirovo",
"filters": "Filtri",
"filters-enabled": "Filteri omogućeni",
"first-click": "Prvi klik",
"first-seen": "Prvi put viđeno",
"funnel": "Lijevak",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Combinat",
"filter-raw": "En cru",
"filters": "Filtres",
"filters-enabled": "Filtres activats",
"first-click": "Primer clic",
"first-seen": "Vist per primer cop",
"funnel": "Embut",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Kombinace",
"filter-raw": "Nezpracované",
"filters": "Filtry",
"filters-enabled": "Filtry povoleny",
"first-click": "První kliknutí",
"first-seen": "Poprvé viděno",
"funnel": "Trychtýř",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Kombineret",
"filter-raw": "Rå",
"filters": "Filtre",
"filters-enabled": "Filtre aktiveret",
"first-click": "Første klik",
"first-seen": "Først set",
"funnel": "Tragt",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Kombiniert",
"filter-raw": "Rohdate",
"filters": "Filter",
"filters-enabled": "Filter aktiviert",
"first-click": "Erste Klick",
"first-seen": "Erstmal gse",
"funnel": "Tunnel",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Kombiniert",
"filter-raw": "Rohdaten",
"filters": "Filter",
"filters-enabled": "Filter aktiviert",
"first-click": "Erster Klick",
"first-seen": "Erstmalig gesehen",
"funnel": "Trichter",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "Σε συνδυασμό",
"filter-raw": "Ακατέργαστο",
"filters": "Φίλτρα",
"filters-enabled": "Φίλτρα ενεργοποιημένα",
"first-click": "Πρώτο κλικ",
"first-seen": "Πρώτη εμφάνιση",
"funnel": "Χωνί",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Combined",
"filter-raw": "Raw",
"filters": "Filters",
"filters-enabled": "Filters enabled",
"first-click": "First click",
"first-seen": "First seen",
"funnel": "Funnel",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Combined",
"filter-raw": "Raw",
"filters": "Filters",
"filters-enabled": "Filters enabled",
"property-filter": "Property Filter",
"activity-by-property": "Activity by property",
"first-click": "First click",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Combinado",
"filter-raw": "En crudo",
"filters": "Filtros",
"filters-enabled": "Filtros activados",
"first-click": "Primer clic",
"first-seen": "Primera vez visto",
"funnel": "Embudo",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "ترکیب شده",
"filter-raw": "خام",
"filters": "فیلترها",
"filters-enabled": "فیلترها فعال",
"first-click": "اولین کلیک",
"first-seen": "اولین بار دیده شده",
"funnel": "فانل",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Yhdistetty",
"filter-raw": "Käsittelemätön",
"filters": "Suodattimet",
"filters-enabled": "Suodattimet käytössä",
"first-click": "Ensimmäinen klikkaus",
"first-seen": "Ensimmäinen havainto",
"funnel": "Suppilo",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Samansett",
"filter-raw": "Óviðgjørt",
"filters": "Síur",
"filters-enabled": "Síur virknaðar",
"first-click": "Fyrsta trýst",
"first-seen": "Fyrst sæddur",
"funnel": "Traktari",
+1
View File
@@ -126,6 +126,7 @@
"filter-combined": "Combiné",
"filter-raw": "Brut",
"filters": "Filtres",
"filters-enabled": "Filtres activés",
"first-click": "Premier clic",
"first-seen": "Vu pour la première fois",
"funnel": "Entonnoir",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Combinado",
"filter-raw": "Crú",
"filters": "Filtros",
"filters-enabled": "Filtros activados",
"first-click": "Primeiro clic",
"first-seen": "Primeira visita",
"funnel": "Funil",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "משותף",
"filter-raw": "גולמי",
"filters": "מסננים",
"filters-enabled": "מסננים מופעלים",
"first-click": "קליק ראשון",
"first-seen": "נראה לראשונה",
"funnel": "משפך",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "संयुक्त",
"filter-raw": "रॉ",
"filters": "फ़िल्टर",
"filters-enabled": "फ़िल्टर सक्षम",
"first-click": "पहला क्लिक",
"first-seen": "पहली बार देखा गया",
"funnel": "फनल",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Kombinirano",
"filter-raw": "Neobrađeno",
"filters": "Filteri",
"filters-enabled": "Filtri omogućeni",
"first-click": "Prvi klik",
"first-seen": "Prvi put viđeno",
"funnel": "Lijevak",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Összevont",
"filter-raw": "Nyers",
"filters": "Szűrők",
"filters-enabled": "Szűrők engedélyezve",
"first-click": "Első kattintás",
"first-seen": "Első megtekintés",
"funnel": "Tölcsér",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Gabungan",
"filter-raw": "Mentah",
"filters": "Filter",
"filters-enabled": "Filter diaktifkan",
"first-click": "Klik pertama",
"first-seen": "Pertama kali dilihat",
"funnel": "Corong",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Aggregati",
"filter-raw": "Grezzo",
"filters": "Filtri",
"filters-enabled": "Filtri abilitati",
"first-click": "Primo clic",
"first-seen": "Prima visualizzazione",
"funnel": "Funnel",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "結合",
"filter-raw": "RAW",
"filters": "フィルター",
"filters-enabled": "フィルター有効",
"first-click": "最初のクリック",
"first-seen": "初回ログイン",
"funnel": "ファネル",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "រួមបញ្ចូលគ្នា",
"filter-raw": "ដើម",
"filters": "ចម្រោះ",
"filters-enabled": "ការច្រោះបានបើក",
"first-click": "ចុចដំបូង",
"first-seen": "ឃើញដំបូង",
"funnel": "ផ្លូវបង្ហាញ",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "합쳐 보기",
"filter-raw": "전체 보기",
"filters": "필터",
"filters-enabled": "필터 활성화됨",
"first-click": "첫 클릭",
"first-seen": "첫 접속",
"funnel": "퍼널",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Kombinuoti",
"filter-raw": "Neapdoroti",
"filters": "Filtrai",
"filters-enabled": "Filtrai įjungti",
"first-click": "Pirmas paspaudimas",
"first-seen": "Pirmą kartą matyta",
"funnel": "Piltuvas",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "Нэгтгэсэн",
"filter-raw": "Түүхий",
"filters": "Шүүлтүүр",
"filters-enabled": "Шүүлтүүр идэвхжүүлэгдсэн",
"first-click": "Эхний даралт",
"first-seen": "Анх харсан",
"funnel": "Цутгал",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Digabungkan",
"filter-raw": "Mentah",
"filters": "Tapis",
"filters-enabled": "Penapis diaktifkan",
"first-click": "Klik pertama",
"first-seen": "Pertama dilihat",
"funnel": "Corong",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "ပေါင်းစပ်ပြီး",
"filter-raw": "အရှိအတိုင်း",
"filters": "Filter များ",
"filters-enabled": "စစ်ထုတ်မှုများ ဖွင့်ထားသည်",
"first-click": "ပထမဆုံးနှိပ်ချက်",
"first-seen": "ပထမဆုံးတွေ့ရှိချိန်",
"funnel": "ဖန်နယ်",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Kombinert",
"filter-raw": "Rå",
"filters": "Filter",
"filters-enabled": "Filtre aktivert",
"first-click": "Første klikk",
"first-seen": "Først sett",
"funnel": "Trakt",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Gecombineerd",
"filter-raw": "Ruw",
"filters": "Filters",
"filters-enabled": "Filters ingeschakeld",
"first-click": "Eerste klik",
"first-seen": "Eerst gezien",
"funnel": "Trechter",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Połączone",
"filter-raw": "Surowe dane",
"filters": "Filtry",
"filters-enabled": "Filtry włączone",
"first-click": "Pierwsze kliknięcie",
"first-seen": "Pierwsza wizyta",
"funnel": "Lejek",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Combinado",
"filter-raw": "Bruto",
"filters": "Filtros",
"filters-enabled": "Filtros ativados",
"first-click": "Primeiro clique",
"first-seen": "Visto pela primeira vez",
"funnel": "Funil",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Combinado",
"filter-raw": "Dados brutos",
"filters": "Filtros",
"filters-enabled": "Filtros ativados",
"first-click": "Primeiro clique",
"first-seen": "Primeira visualização",
"funnel": "Funil",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Combinat",
"filter-raw": "Brut",
"filters": "Filtre",
"filters-enabled": "Filtre activate",
"first-click": "Primul click",
"first-seen": "Văzut pentru prima dată",
"funnel": "Parcursul utilizatorului",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "Объединенные",
"filter-raw": "Сырые данные",
"filters": "Фильтры",
"filters-enabled": "Фильтры включены",
"first-click": "Первый клик",
"first-seen": "Первый вход",
"funnel": "Воронка",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "ඒකාබද්ධ",
"filter-raw": "අමු",
"filters": "පෙරහන්",
"filters-enabled": "පෙරහන් සක්‍රීය කර ඇත",
"first-click": "පළමු ක්ලික්",
"first-seen": "මුලින් දුටු",
"funnel": "පුනීලය",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Kombinácie",
"filter-raw": "Nezpracované",
"filters": "Filtre",
"filters-enabled": "Filtre povolené",
"first-click": "Prvé kliknutie",
"first-seen": "Prvýkrát videné",
"funnel": "Lievik",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Skupaj",
"filter-raw": "Neobdelano",
"filters": "Filtri",
"filters-enabled": "Filtri omogočeni",
"first-click": "Prvi klik",
"first-seen": "Prvič viden",
"funnel": "Prodajni lijak",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Kombinerade",
"filter-raw": "Rådata",
"filters": "Filter",
"filters-enabled": "Filter aktiverade",
"first-click": "Första klicket",
"first-seen": "Först sedd",
"funnel": "Tratt",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "ஒருங்கிணைந்த",
"filter-raw": "மூல",
"filters": "வடிகட்டிகள்",
"filters-enabled": "வடிகட்டிகள் இயக்கப்பட்டன",
"first-click": "முதல் கிளிக்",
"first-seen": "முதலில் பார்த்தது",
"funnel": "புனல்",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "ข้อมูลรวม",
"filter-raw": "ข้อมูลดิบ",
"filters": "ตัวกรอง",
"filters-enabled": "เปิดใช้งานตัวกรอง",
"first-click": "คลิกแรก",
"first-seen": "เห็นครั้งแรก",
"funnel": "ช่องทางขาย",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Birleşik filtre",
"filter-raw": "Ham filtre",
"filters": "Filtreler",
"filters-enabled": "Filtreler etkin",
"first-click": "İlk tıklama",
"first-seen": "İlk görülme",
"funnel": "Huni",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "Об'єднані",
"filter-raw": "Сирі дані",
"filters": "Фільтри",
"filters-enabled": "Фільтри увімкнено",
"first-click": "Перший клік",
"first-seen": "Перший візит",
"funnel": "Воронка",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "مشترکہ",
"filter-raw": "خام",
"filters": "فلٹرز",
"filters-enabled": "فلٹرز فعال",
"first-click": "پہلا کلک",
"first-seen": "پہلی بار دیکھا گیا",
"funnel": "فنل",
+1
View File
@@ -125,6 +125,7 @@
"filter-combined": "Birlashtirilgan",
"filter-raw": "Xom",
"filters": "Filtrlar",
"filters-enabled": "Filtrlar yoqilgan",
"first-click": "Birinchi bosish",
"first-seen": "Birinchi koʻrilgan",
"funnel": "Voronka",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "Kết hợp lọc",
"filter-raw": "Lọc thô",
"filters": "Bộ lọc",
"filters-enabled": "Bộ lọc đã bật",
"first-click": "Nhấp đầu tiên",
"first-seen": "Lần đầu tiên nhìn thấy",
"funnel": "Phễu",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "合并",
"filter-raw": "原始",
"filters": "筛选",
"filters-enabled": "过滤器已启用",
"first-click": "首次点击",
"first-seen": "首次出现",
"funnel": "分析",
+1
View File
@@ -124,6 +124,7 @@
"filter-combined": "組合",
"filter-raw": "原始",
"filters": "篩選條件",
"filters-enabled": "篩選器已啟用",
"first-click": "首次點擊",
"first-seen": "首次造訪",
"funnel": "漏斗分析",
+11 -2
View File
@@ -2,6 +2,7 @@ import { DataColumn, DataTable, Dialog, Icon, MenuItem, Modal, Row, Text } from
import Link from '@/components/common/Link';
import { useState } from 'react';
import { DateDistance } from '@/components/common/DateDistance';
import { SortableLabel } from '@/components/common/SortableLabel';
import { useMessages } from '@/components/hooks';
import { Edit, Trash } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton';
@@ -21,7 +22,11 @@ export function AdminTeamsTable({
return (
<>
<DataTable data={data} {...props}>
<DataColumn id="name" label={t(labels.name)} width="1fr">
<DataColumn
id="name"
label={<SortableLabel label={t(labels.name)} sortKey="name" />}
width="1fr"
>
{(row: any) => <Link href={`/admin/teams/${row.id}`}>{row.name}</Link>}
</DataColumn>
<DataColumn id="websites" label={t(labels.members)} width="140px">
@@ -41,7 +46,11 @@ export function AdminTeamsTable({
);
}}
</DataColumn>
<DataColumn id="created" label={t(labels.created)} width="160px">
<DataColumn
id="created"
label={<SortableLabel label={t(labels.created)} sortKey="createdAt" defaultDirection="desc" />}
width="160px"
>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
{showActions && (
+11 -3
View File
@@ -2,6 +2,7 @@ import { DataColumn, DataTable, Icon, MenuItem, Modal, Row, Text } from '@umami/
import Link from '@/components/common/Link';
import { useState } from 'react';
import { DateDistance } from '@/components/common/DateDistance';
import { SortableLabel } from '@/components/common/SortableLabel';
import { useMessages } from '@/components/hooks';
import { Edit, Trash } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton';
@@ -22,10 +23,14 @@ export function UsersTable({
return (
<>
<DataTable data={data} {...props}>
<DataColumn id="username" label={t(labels.username)} width="2fr">
<DataColumn
id="username"
label={<SortableLabel label={t(labels.username)} sortKey="username" />}
width="2fr"
>
{(row: any) => <Link href={`/admin/users/${row.id}`}>{row.username}</Link>}
</DataColumn>
<DataColumn id="role" label={t(labels.role)}>
<DataColumn id="role" label={<SortableLabel label={t(labels.role)} sortKey="role" />}>
{(row: any) =>
t(labels[Object.keys(ROLES).find(key => ROLES[key] === row.role)] || labels.unknown)
}
@@ -33,7 +38,10 @@ export function UsersTable({
<DataColumn id="websites" label={t(labels.websites)}>
{(row: any) => row._count.websites}
</DataColumn>
<DataColumn id="created" label={t(labels.created)}>
<DataColumn
id="created"
label={<SortableLabel label={t(labels.created)} sortKey="createdAt" defaultDirection="desc" />}
>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
{showActions && (
@@ -3,6 +3,7 @@ import Link from '@/components/common/Link';
import { useState } from 'react';
import { WebsiteDeleteForm } from '@/app/(main)/websites/[websiteId]/settings/WebsiteDeleteForm';
import { DateDistance } from '@/components/common/DateDistance';
import { SortableLabel } from '@/components/common/SortableLabel';
import { useMessages } from '@/components/hooks';
import { Edit, Trash, Users } from '@/components/icons';
import { MenuButton } from '@/components/input/MenuButton';
@@ -14,14 +15,14 @@ export function AdminWebsitesTable({ data = [], ...props }: { data: any[] }) {
return (
<>
<DataTable data={data} {...props}>
<DataColumn id="name" label={t(labels.name)}>
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
{(row: any) => (
<Text truncate>
<Link href={`/admin/websites/${row.id}`}>{row.name}</Link>
</Text>
)}
</DataColumn>
<DataColumn id="domain" label={t(labels.domain)}>
<DataColumn id="domain" label={<SortableLabel label={t(labels.domain)} sortKey="domain" />}>
{(row: any) => <Text truncate>{row.domain}</Text>}
</DataColumn>
<DataColumn id="owner" label={t(labels.owner)}>
@@ -45,7 +46,11 @@ export function AdminWebsitesTable({ data = [], ...props }: { data: any[] }) {
);
}}
</DataColumn>
<DataColumn id="created" label={t(labels.created)} width="180px">
<DataColumn
id="created"
label={<SortableLabel label={t(labels.created)} sortKey="createdAt" defaultDirection="desc" />}
width="180px"
>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
<DataColumn id="action" align="end" width="50px">
+12 -4
View File
@@ -1,6 +1,7 @@
import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
import Link from '@/components/common/Link';
import { DateDistance } from '@/components/common/DateDistance';
import { SortableLabel } from '@/components/common/SortableLabel';
import { useMessages, useNavigation } from '@/components/hooks';
import { BoardDeleteButton } from './BoardDeleteButton';
import { BoardDesignButton } from './BoardDesignButton';
@@ -12,16 +13,23 @@ export function BoardsTable(props: DataTableProps) {
return (
<DataTable {...props}>
<DataColumn id="name" label={t(labels.name)}>
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
{({ id, name }: any) => {
return <Link href={renderUrl(`/boards/${id}`)}>{name}</Link>;
}}
</DataColumn>
<DataColumn id="description" label={t(labels.description)} />
<DataColumn id="type" label={t(labels.boardType)}>
<DataColumn
id="description"
label={<SortableLabel label={t(labels.description)} sortKey="description" />}
/>
<DataColumn id="type" label={<SortableLabel label={t(labels.boardType)} sortKey="type" />}>
{({ type }: any) => type ? type.charAt(0).toUpperCase() + type.slice(1) : ''}
</DataColumn>
<DataColumn id="created" label={t(labels.created)} width="200px">
<DataColumn
id="created"
label={<SortableLabel label={t(labels.created)} sortKey="createdAt" defaultDirection="desc" />}
width="200px"
>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
<DataColumn id="action" align="end" width="100px">
@@ -1,6 +1,16 @@
import { Button, Column, Form, FormField, FormSubmitButton, Row, TextField } from '@umami/react-zen';
import {
Button,
Checkbox,
Column,
Form,
FormField,
FormSubmitButton,
Row,
TextField,
} from '@umami/react-zen';
import { useState } from 'react';
import { useApi, useMessages, useModified } from '@/components/hooks';
import { ThemeModeSelector } from '@/components/input/ThemeModeSelector';
export function BoardShareCreateForm({
boardId,
@@ -15,15 +25,19 @@ export function BoardShareCreateForm({
const { touch } = useModified();
const { t, labels, getErrorMessage } = useMessages();
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<unknown>(null);
const [error, setError] = useState<any>(null);
const handleSubmit = async (data: { name: string }) => {
const handleSubmit = async (data: { name: string; allowFilter?: boolean; theme?: string }) => {
setIsPending(true);
setError(null);
try {
await post(`/boards/${boardId}/shares`, {
name: data.name,
parameters: {
allowFilter: data.allowFilter ?? true,
theme: data.theme === 'system' ? undefined : data.theme,
},
});
touch('shares');
@@ -36,22 +50,38 @@ export function BoardShareCreateForm({
};
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={{ name: '' }}>
<Column gap="4">
<FormField label={t(labels.name)} name="name" rules={{ required: t(labels.required) }}>
<TextField autoComplete="off" autoFocus />
</FormField>
<Row justifyContent="flex-end" gap="3">
{onCancel && (
<Button isDisabled={isPending} onPress={onCancel}>
{t(labels.cancel)}
</Button>
)}
<FormSubmitButton variant="primary" isDisabled={isPending}>
{t(labels.add)}
</FormSubmitButton>
</Row>
</Column>
<Form
onSubmit={handleSubmit}
error={getErrorMessage(error)}
defaultValues={{ name: '', allowFilter: true, theme: 'system' }}
>
{({ watch, setValue }) => (
<Column gap="4">
<FormField label={t(labels.name)} name="name" rules={{ required: t(labels.required) }}>
<TextField autoComplete="off" autoFocus />
</FormField>
<FormField name="allowFilter">
<Checkbox>{t(labels.filters)}</Checkbox>
</FormField>
<FormField label={t(labels.theme)} name="theme">
<ThemeModeSelector
value={watch('theme')}
includeSystem
onChange={value => setValue('theme', value, { shouldDirty: true })}
/>
</FormField>
<Row justifyContent="flex-end" gap="3">
{onCancel && (
<Button isDisabled={isPending} onPress={onCancel}>
{t(labels.cancel)}
</Button>
)}
<FormSubmitButton variant="primary" isDisabled={isPending}>
{t(labels.add)}
</FormSubmitButton>
</Row>
</Column>
)}
</Form>
);
}
@@ -3,6 +3,7 @@ import { CopyButton } from '@/components/common/CopyButton';
import { ExternalLink } from '@/components/common/ExternalLink';
import { useConfig, useMessages, useMobile } from '@/components/hooks';
import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-zen';
import { SimpleShareEditButton } from '@/components/share/SimpleShareEditButton';
export function BoardSharesTable(props: DataTableProps) {
const { t, labels } = useMessages();
@@ -36,9 +37,10 @@ export function BoardSharesTable(props: DataTableProps) {
);
}}
</DataColumn>
<DataColumn id="action" align="end" width="60px">
<DataColumn id="action" align="end" width="100px">
{({ id, slug }: any) => (
<Row>
<SimpleShareEditButton shareId={id} />
<ShareDeleteButton shareId={id} slug={slug} />
</Row>
)}
+9 -4
View File
@@ -2,6 +2,7 @@ import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-ze
import Link from '@/components/common/Link';
import { DateDistance } from '@/components/common/DateDistance';
import { ExternalLink } from '@/components/common/ExternalLink';
import { SortableLabel } from '@/components/common/SortableLabel';
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
import { LinkDeleteButton } from './LinkDeleteButton';
import { LinkEditButton } from './LinkEditButton';
@@ -17,23 +18,27 @@ export function LinksTable({ showActions, ...props }: LinksTableProps) {
return (
<DataTable {...props}>
<DataColumn id="name" label={t(labels.name)}>
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
{({ id, name }: any) => {
return <Link href={renderUrl(`/links/${id}`)}>{name}</Link>;
}}
</DataColumn>
<DataColumn id="slug" label={t(labels.link)}>
<DataColumn id="slug" label={<SortableLabel label={t(labels.link)} sortKey="slug" />}>
{({ slug }: any) => {
const url = getSlugUrl(slug);
return <ExternalLink href={url}>{url}</ExternalLink>;
}}
</DataColumn>
<DataColumn id="url" label={t(labels.destinationUrl)}>
<DataColumn id="url" label={<SortableLabel label={t(labels.destinationUrl)} sortKey="url" />}>
{({ url }: any) => {
return <ExternalLink href={url}>{url}</ExternalLink>;
}}
</DataColumn>
<DataColumn id="created" label={t(labels.created)} width="200px">
<DataColumn
id="created"
label={<SortableLabel label={t(labels.created)} sortKey="createdAt" defaultDirection="desc" />}
width="200px"
>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
{showActions && (
@@ -1,9 +1,11 @@
import { Column, Row } from '@umami/react-zen';
import { useShare } from '@/components/hooks';
import { ExportButton } from '@/components/input/ExportButton';
import { FilterBar } from '@/components/input/FilterBar';
import { MonthFilter } from '@/components/input/MonthFilter';
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
import { WebsiteFilterButton } from '@/components/input/WebsiteFilterButton';
import { allowShareFilter } from '@/lib/share';
export function LinkControls({
linkId: websiteId,
@@ -18,15 +20,18 @@ export function LinkControls({
allowMonthFilter?: boolean;
allowDownload?: boolean;
}) {
const share = useShare();
const showFilter = allowFilter && allowShareFilter(share?.parameters);
return (
<Column gap>
<Row alignItems="center" justifyContent="space-between" gap="3">
{allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
{showFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
{allowDateFilter && <WebsiteDateFilter websiteId={websiteId} showAllTime={false} />}
{allowDownload && <ExportButton websiteId={websiteId} />}
{allowMonthFilter && <MonthFilter />}
</Row>
{allowFilter && <FilterBar websiteId={websiteId} />}
{showFilter && <FilterBar websiteId={websiteId} />}
</Column>
);
}
+7 -3
View File
@@ -2,6 +2,7 @@ import { DataColumn, DataTable, type DataTableProps, Row } from '@umami/react-ze
import Link from '@/components/common/Link';
import { DateDistance } from '@/components/common/DateDistance';
import { ExternalLink } from '@/components/common/ExternalLink';
import { SortableLabel } from '@/components/common/SortableLabel';
import { useMessages, useNavigation, useSlug } from '@/components/hooks';
import { PixelDeleteButton } from './PixelDeleteButton';
import { PixelEditButton } from './PixelEditButton';
@@ -17,12 +18,12 @@ export function PixelsTable({ showActions, ...props }: PixelsTableProps) {
return (
<DataTable {...props}>
<DataColumn id="name" label={t(labels.name)}>
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
{({ id, name }: any) => {
return <Link href={renderUrl(`/pixels/${id}`)}>{name}</Link>;
}}
</DataColumn>
<DataColumn id="url" label="URL">
<DataColumn id="url" label={<SortableLabel label="URL" sortKey="slug" />}>
{({ slug }: any) => {
const url = getSlugUrl(slug);
return (
@@ -32,7 +33,10 @@ export function PixelsTable({ showActions, ...props }: PixelsTableProps) {
);
}}
</DataColumn>
<DataColumn id="created" label={t(labels.created)}>
<DataColumn
id="created"
label={<SortableLabel label={t(labels.created)} sortKey="createdAt" defaultDirection="desc" />}
>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
{showActions && (
@@ -1,9 +1,11 @@
import { Column, Row } from '@umami/react-zen';
import { useShare } from '@/components/hooks';
import { ExportButton } from '@/components/input/ExportButton';
import { FilterBar } from '@/components/input/FilterBar';
import { MonthFilter } from '@/components/input/MonthFilter';
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
import { WebsiteFilterButton } from '@/components/input/WebsiteFilterButton';
import { allowShareFilter } from '@/lib/share';
export function PixelControls({
pixelId: websiteId,
@@ -18,15 +20,18 @@ export function PixelControls({
allowMonthFilter?: boolean;
allowDownload?: boolean;
}) {
const share = useShare();
const showFilter = allowFilter && allowShareFilter(share?.parameters);
return (
<Column gap>
<Row alignItems="center" justifyContent="space-between" gap="3">
{allowFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
{showFilter ? <WebsiteFilterButton websiteId={websiteId} /> : <div />}
{allowDateFilter && <WebsiteDateFilter websiteId={websiteId} showAllTime={false} />}
{allowDownload && <ExportButton websiteId={websiteId} />}
{allowMonthFilter && <MonthFilter />}
</Row>
{allowFilter && <FilterBar websiteId={websiteId} />}
{showFilter && <FilterBar websiteId={websiteId} />}
</Column>
);
}
@@ -1,21 +1,8 @@
import { Button, Icon, Row, useTheme } from '@umami/react-zen';
import { Moon, Sun } from '@/components/icons';
import { useTheme } from '@umami/react-zen';
import { ThemeModeSelector } from '@/components/input/ThemeModeSelector';
export function ThemeSetting() {
const { theme, setTheme } = useTheme();
return (
<Row gap>
<Button variant={theme === 'light' ? 'primary' : undefined} onPress={() => setTheme('light')}>
<Icon>
<Sun />
</Icon>
</Button>
<Button variant={theme === 'dark' ? 'primary' : undefined} onPress={() => setTheme('dark')}>
<Icon>
<Moon />
</Icon>
</Button>
</Row>
);
return <ThemeModeSelector value={theme} onChange={value => setTheme(value as 'light' | 'dark')} />;
}
+2 -1
View File
@@ -1,5 +1,6 @@
import { DataColumn, DataTable, type DataTableProps } from '@umami/react-zen';
import type { ReactNode } from 'react';
import { SortableLabel } from '@/components/common/SortableLabel';
import { useMessages } from '@/components/hooks';
import { ROLES } from '@/lib/constants';
@@ -12,7 +13,7 @@ export function TeamsTable({ renderLink, ...props }: TeamsTableProps) {
return (
<DataTable {...props}>
<DataColumn id="name" label={t(labels.name)}>
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
{renderLink}
</DataColumn>
<DataColumn id="owner" label={t(labels.owner)}>
@@ -2,6 +2,7 @@ import { DataColumn, DataTable, Row } from '@umami/react-zen';
import Link from '@/components/common/Link';
import { TeamMemberEditButton } from '@/app/(main)/teams/[teamId]/TeamMemberEditButton';
import { TeamMemberRemoveButton } from '@/app/(main)/teams/[teamId]/TeamMemberRemoveButton';
import { SortableLabel } from '@/components/common/SortableLabel';
import { useMessages } from '@/components/hooks';
import { ROLES } from '@/lib/constants';
@@ -18,10 +19,10 @@ export function TeamWebsitesTable({
return (
<DataTable data={data}>
<DataColumn id="name" label={t(labels.name)}>
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
{(row: any) => <Link href={`/teams/${teamId}/websites/${row.id}`}>{row.name}</Link>}
</DataColumn>
<DataColumn id="domain" label={t(labels.domain)} />
<DataColumn id="domain" label={<SortableLabel label={t(labels.domain)} sortKey="domain" />} />
<DataColumn id="createdBy" label={t(labels.createdBy)}>
{(row: any) => row?.createUser?.username}
</DataColumn>
+11 -2
View File
@@ -1,6 +1,8 @@
import { DataColumn, DataTable, type DataTableProps, Icon } from '@umami/react-zen';
import type { ReactNode } from 'react';
import { DateDistance } from '@/components/common/DateDistance';
import { LinkButton } from '@/components/common/LinkButton';
import { SortableLabel } from '@/components/common/SortableLabel';
import { useMessages, useNavigation } from '@/components/hooks';
import { SquarePen } from '@/components/icons';
@@ -17,10 +19,17 @@ export function WebsitesTable({ showActions, renderLink, ...props }: WebsitesTab
return (
<DataTable {...props}>
<DataColumn id="name" label={t(labels.name)}>
<DataColumn id="name" label={<SortableLabel label={t(labels.name)} sortKey="name" />}>
{renderLink}
</DataColumn>
<DataColumn id="domain" label={t(labels.domain)} />
<DataColumn id="domain" label={<SortableLabel label={t(labels.domain)} sortKey="domain" />} />
<DataColumn
id="created"
label={<SortableLabel label={t(labels.created)} sortKey="createdAt" defaultDirection="desc" />}
width="200px"
>
{(row: any) => <DateDistance date={new Date(row.createdAt)} />}
</DataColumn>
{showActions && (
<DataColumn id="action" label=" " align="end">
{(row: any) => {
@@ -1,9 +1,11 @@
import { Column, Grid, Row } from '@umami/react-zen';
import { useShare } from '@/components/hooks';
import { ExportButton } from '@/components/input/ExportButton';
import { FilterBar } from '@/components/input/FilterBar';
import { MonthFilter } from '@/components/input/MonthFilter';
import { WebsiteDateFilter } from '@/components/input/WebsiteDateFilter';
import { WebsiteFilterButton } from '@/components/input/WebsiteFilterButton';
import { allowShareFilter } from '@/lib/share';
export function WebsiteControls({
websiteId,
@@ -22,11 +24,16 @@ export function WebsiteControls({
allowDownload?: boolean;
allowCompare?: boolean;
}) {
const share = useShare();
const showFilter = allowFilter && allowShareFilter(share?.parameters);
return (
<Column gap>
<Grid columns={{ base: '1fr', md: 'auto 1fr' }} gap>
<Row alignItems="center" justifyContent="flex-start" gap="4">
{allowFilter && <WebsiteFilterButton websiteId={websiteId} allowBounceFilter={allowBounceFilter} />}
{showFilter && (
<WebsiteFilterButton websiteId={websiteId} allowBounceFilter={allowBounceFilter} />
)}
</Row>
<Row alignItems="center" justifyContent={{ base: 'flex-start', md: 'flex-end' }}>
{allowDateFilter && (
@@ -36,7 +43,7 @@ export function WebsiteControls({
{allowMonthFilter && <MonthFilter />}
</Row>
</Grid>
{allowFilter && <FilterBar websiteId={websiteId} />}
{showFilter && <FilterBar websiteId={websiteId} />}
</Column>
);
}
@@ -7,13 +7,16 @@ import {
FormSubmitButton,
Grid,
Label,
ListSeparator,
Loading,
Row,
Switch,
Text,
TextField,
} from '@umami/react-zen';
import { useEffect, useState } from 'react';
import { useApi, useConfig, useMessages, useModified } from '@/components/hooks';
import { ThemeModeSelector } from '@/components/input/ThemeModeSelector';
import { SHARE_NAV_ITEMS } from './constants';
export function ShareEditForm({
@@ -59,12 +62,14 @@ export function ShareEditForm({
}, [shareId, modified]);
const handleSubmit = async (data: any) => {
const parameters: Record<string, boolean> = {};
const parameters: Record<string, boolean | string | undefined> = {};
SHARE_NAV_ITEMS.forEach(section => {
section.items.forEach(item => {
parameters[item.id] = data[item.id] ?? false;
});
});
parameters.allowFilter = data.allowFilter ?? true;
parameters.theme = data.theme === 'system' ? undefined : data.theme;
setIsPending(true);
setError(null);
@@ -101,6 +106,8 @@ export function ShareEditForm({
// Build default values from share parameters
const defaultValues: Record<string, any> = {
name: share?.name || '',
allowFilter: share?.parameters?.allowFilter ?? true,
theme: share?.parameters?.theme || 'system',
};
SHARE_NAV_ITEMS.forEach(section => {
section.items.forEach(item => {
@@ -114,7 +121,7 @@ export function ShareEditForm({
return (
<Form onSubmit={handleSubmit} error={getErrorMessage(error)} defaultValues={defaultValues}>
{({ watch }) => {
{({ watch, setValue }) => {
const values = watch();
const hasSelection = allItemIds.some(id => values[id]);
@@ -129,7 +136,25 @@ export function ShareEditForm({
<FormField label={t(labels.name)} name="name" rules={{ required: t(labels.required) }}>
<TextField autoComplete="off" autoFocus={!isEditing} />
</FormField>
<Grid columns="repeat(auto-fit, minmax(150px, 1fr))" gap="3">
<Grid columns={{ base: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap="3">
<FormField label={t(labels.filters)} name="allowFilter">
<Switch
isSelected={watch('allowFilter')}
onChange={value => setValue('allowFilter', value, { shouldDirty: true })}
>
{t(labels.filtersEnabled)}
</Switch>
</FormField>
<FormField label={t(labels.theme)} name="theme">
<ThemeModeSelector
value={watch('theme')}
includeSystem
onChange={value => setValue('theme', value, { shouldDirty: true })}
/>
</FormField>
</Grid>
<ListSeparator />
<Grid columns={{ base: 'repeat(2, 1fr)', lg: 'repeat(3, 1fr)' }} gap="3">
{SHARE_NAV_ITEMS.map(section => (
<Column key={section.section} gap="3">
<Text weight="bold">{t((labels as any)[section.section])}</Text>
+2 -1
View File
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canViewAllTeams } from '@/permissions';
import { getTeams } from '@/queries/prisma/team';
@@ -9,6 +9,7 @@ export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canViewUsers } from '@/permissions';
import { getUsers } from '@/queries/prisma/user';
@@ -9,6 +9,7 @@ export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -2,7 +2,7 @@ import { z } from 'zod';
import { ROLES } from '@/lib/constants';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canViewAllWebsites } from '@/permissions';
import { getWebsites } from '@/queries/prisma/website';
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
+5 -3
View File
@@ -4,7 +4,7 @@ import { uuid } from '@/lib/crypto';
import { getRandomChars } from '@/lib/generate';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { filterParams, pagingParams } from '@/lib/schema';
import { anyObjectParam, filterParams, pagingParams } from '@/lib/schema';
import { canUpdateBoard, canViewBoard } from '@/permissions';
import { createShare, getSharesByEntityId } from '@/queries/prisma';
@@ -45,6 +45,7 @@ export async function POST(
) {
const schema = z.object({
name: z.string().max(200),
parameters: anyObjectParam.optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
@@ -54,7 +55,8 @@ export async function POST(
}
const { boardId } = await params;
const { name } = body;
const { name, parameters } = body;
const shareParameters = parameters ?? {};
if (!(await canUpdateBoard(auth, boardId))) {
return unauthorized();
@@ -66,7 +68,7 @@ export async function POST(
shareType: ENTITY_TYPE.board,
name,
slug: getRandomChars(16),
parameters: {},
parameters: shareParameters,
});
return json(share);
+2 -1
View File
@@ -3,7 +3,7 @@ import { BOARD_TYPES, normalizeBoardType } from '@/lib/boards';
import { uuid } from '@/lib/crypto';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
import { createBoard, getUserBoards } from '@/queries/prisma';
@@ -11,6 +11,7 @@ export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
+5 -3
View File
@@ -4,7 +4,7 @@ import { uuid } from '@/lib/crypto';
import { getRandomChars } from '@/lib/generate';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { filterParams, pagingParams } from '@/lib/schema';
import { anyObjectParam, filterParams, pagingParams } from '@/lib/schema';
import { canUpdateLink, canViewLink } from '@/permissions';
import { createShare, getSharesByEntityId } from '@/queries/prisma';
@@ -45,6 +45,7 @@ export async function POST(
) {
const schema = z.object({
name: z.string().max(200),
parameters: anyObjectParam.optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
@@ -54,7 +55,8 @@ export async function POST(
}
const { linkId } = await params;
const { name } = body;
const { name, parameters } = body;
const shareParameters = parameters ?? {};
if (!(await canUpdateLink(auth, linkId))) {
return unauthorized();
@@ -66,7 +68,7 @@ export async function POST(
shareType: ENTITY_TYPE.link,
name,
slug: getRandomChars(16),
parameters: {},
parameters: shareParameters,
});
return json(share);
+2 -1
View File
@@ -2,7 +2,7 @@ import { z } from 'zod';
import { uuid } from '@/lib/crypto';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
import { createLink, getUserLinks } from '@/queries/prisma';
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -1,12 +1,13 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json } from '@/lib/response';
import { pagingParams } from '@/lib/schema';
import { pagingParams, sortingParams } from '@/lib/schema';
import { getUserTeams } from '@/queries/prisma';
export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...sortingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -1,12 +1,13 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json } from '@/lib/response';
import { pagingParams } from '@/lib/schema';
import { pagingParams, sortingParams } from '@/lib/schema';
import { getAllUserWebsitesIncludingTeamAccess, getUserWebsites } from '@/queries/prisma';
export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...sortingParams,
includeTeams: z.string().optional(),
});
+5 -3
View File
@@ -4,7 +4,7 @@ import { uuid } from '@/lib/crypto';
import { getRandomChars } from '@/lib/generate';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { filterParams, pagingParams } from '@/lib/schema';
import { anyObjectParam, filterParams, pagingParams } from '@/lib/schema';
import { canUpdatePixel, canViewPixel } from '@/permissions';
import { createShare, getSharesByEntityId } from '@/queries/prisma';
@@ -45,6 +45,7 @@ export async function POST(
) {
const schema = z.object({
name: z.string().max(200),
parameters: anyObjectParam.optional(),
});
const { auth, body, error } = await parseRequest(request, schema);
@@ -54,7 +55,8 @@ export async function POST(
}
const { pixelId } = await params;
const { name } = body;
const { name, parameters } = body;
const shareParameters = parameters ?? {};
if (!(await canUpdatePixel(auth, pixelId))) {
return unauthorized();
@@ -66,7 +68,7 @@ export async function POST(
shareType: ENTITY_TYPE.pixel,
name,
slug: getRandomChars(16),
parameters: {},
parameters: shareParameters,
});
return json(share);
+2 -1
View File
@@ -2,7 +2,7 @@ import { z } from 'zod';
import { uuid } from '@/lib/crypto';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
import { createPixel, getUserPixels } from '@/queries/prisma';
@@ -10,6 +10,7 @@ export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canViewTeam } from '@/permissions';
import { getTeamBoards } from '@/queries/prisma';
@@ -9,6 +9,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ team
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
});
const { teamId } = await params;
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canViewTeam } from '@/permissions';
import { getTeamLinks } from '@/queries/prisma';
@@ -9,6 +9,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ team
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
});
const { teamId } = await params;
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canViewTeam } from '@/permissions';
import { getTeamPixels } from '@/queries/prisma';
@@ -9,6 +9,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ team
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
});
const { teamId } = await params;
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -1,7 +1,7 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canViewTeam } from '@/permissions';
import { getTeamWebsites } from '@/queries/prisma';
@@ -9,6 +9,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ team
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
});
const { teamId } = await params;
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -5,13 +5,14 @@ import { fetchAccount } from '@/lib/load';
import redis from '@/lib/redis';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams } from '@/lib/schema';
import { pagingParams, sortingParams } from '@/lib/schema';
import { canCreateTeam } from '@/permissions';
import { createTeam, getUserTeams } from '@/queries/prisma';
export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...sortingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -1,12 +1,13 @@
import { z } from 'zod';
import { parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams } from '@/lib/schema';
import { pagingParams, sortingParams } from '@/lib/schema';
import { getUserTeams } from '@/queries/prisma';
export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
const schema = z.object({
...pagingParams,
...sortingParams,
});
const { auth, query, error } = await parseRequest(request, schema);
+2 -1
View File
@@ -1,13 +1,14 @@
import { z } from 'zod';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { getAllUserWebsitesIncludingTeamAccess, getUserWebsites } from '@/queries/prisma/website';
export async function GET(request: Request, { params }: { params: Promise<{ userId: string }> }) {
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
includeTeams: z.string().optional(),
});
+2 -1
View File
@@ -4,7 +4,7 @@ import { uuid } from '@/lib/crypto';
import { fetchAccount } from '@/lib/load';
import { getQueryFilters, parseRequest } from '@/lib/request';
import { json, unauthorized } from '@/lib/response';
import { pagingParams, searchParams } from '@/lib/schema';
import { pagingParams, searchParams, sortingParams } from '@/lib/schema';
import { canCreateTeamWebsite, canCreateWebsite } from '@/permissions';
import { createShare, createWebsite, getWebsiteCount } from '@/queries/prisma';
import { getAllUserWebsitesIncludingTeamAccess, getUserWebsites } from '@/queries/prisma/website';
@@ -15,6 +15,7 @@ export async function GET(request: Request) {
const schema = z.object({
...pagingParams,
...searchParams,
...sortingParams,
includeTeams: z.string().optional(),
});
+2 -2
View File
@@ -1,7 +1,7 @@
'use client';
import { useShareTokenQuery } from '@/components/hooks';
import { ENTITY_TYPE } from '@/lib/constants';
import type { WhiteLabel } from '@/lib/types';
import type { ShareParameters, WhiteLabel } from '@/lib/types';
import { Loading } from '@umami/react-zen';
import { usePathname, useRouter } from 'next/navigation';
import { createContext, type ReactNode, useEffect } from 'react';
@@ -15,7 +15,7 @@ export interface ShareData {
boardId?: string;
pixelId?: string;
linkId?: string;
parameters: any;
parameters: ShareParameters;
token: string;
whiteLabel?: WhiteLabel;
}
+11 -3
View File
@@ -3,6 +3,7 @@ import { useMessages, useNavigation, useShare } from '@/components/hooks';
import { AlignEndHorizontal, Clock, Eye, PanelLeft, Sheet, Tag, User } from '@/components/icons';
import { LanguageButton } from '@/components/input/LanguageButton';
import { PreferencesButton } from '@/components/input/PreferencesButton';
import { allowShareFilter, excludeShareFilterParam, getShareTheme } from '@/lib/share';
import { Funnel, Gauge, Lightning, Magnet, Money, Network, Path, Target } from '@/components/svg';
import { buildPath } from '@/lib/url';
import {
@@ -32,12 +33,19 @@ export function ShareNav({
const { t, labels } = useMessages();
const { pathname, query } = useNavigation();
const { slug, parameters } = share;
const allowFilter = allowShareFilter(parameters);
const shareTheme = getShareTheme(parameters);
const renderPath = (path: string) =>
buildPath(`/share/${slug}${path}`, {
...query,
...Object.fromEntries(
Object.entries(query).filter(([key]) => {
return allowFilter || !excludeShareFilterParam(key);
}),
),
event: undefined,
compare: undefined,
theme: undefined,
view: undefined,
unit: undefined,
excludeBounce: undefined,
@@ -202,13 +210,13 @@ export function ShareNav({
>
{collapsed ? (
<Column gap="2" alignItems="center">
<ThemeButton />
{!shareTheme && <ThemeButton />}
<LanguageButton />
<PreferencesButton />
</Column>
) : (
<Row>
<ThemeButton />
{!shareTheme && <ThemeButton />}
<LanguageButton />
<PreferencesButton />
</Row>
@@ -22,6 +22,7 @@ import { PageBody } from '@/components/common/PageBody';
import { useShare } from '@/components/hooks';
import { MobileMenuButton } from '@/components/input/MobileMenuButton';
import { ENTITY_TYPE } from '@/lib/constants';
import { getShareTheme } from '@/lib/share';
import { Column, Grid, Row, useTheme } from '@umami/react-zen';
import { usePathname, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
@@ -68,20 +69,20 @@ export function SharePage() {
setNavCollapsed(value);
};
const share = useShare();
const { setTheme } = useTheme();
const { initTheme } = useTheme();
const router = useRouter();
const pathname = usePathname();
const path = getSharePath(pathname);
const { slug, websiteId, boardId, pixelId, linkId, parameters = {}, shareType } = share;
const shareTheme = getShareTheme(parameters);
useEffect(() => {
const url = new URL(window?.location?.href);
const theme = url.searchParams.get('theme');
initTheme(shareTheme, 'system');
if (theme === 'light' || theme === 'dark') {
setTheme(theme);
}
}, [setTheme]);
return () => {
initTheme(undefined, 'system');
};
}, [shareTheme, initTheme]);
// Check if the requested path is allowed
const pageKey = path || '';
+37 -37
View File
@@ -41,7 +41,7 @@ export function DataGrid({
const { data, error, isLoading, isFetching } = query;
const { router, updateParams, query: queryParams } = useNavigation();
const [search, setSearch] = useState(queryParams?.search || data?.search || '');
const showPager = allowPaging && data && (data.count > data.pageSize || data.isCapped);
const showPager = allowPaging && data && data.count > 0;
const { isMobile } = useMobile();
const displayMode = isMobile ? 'cards' : undefined;
@@ -62,47 +62,47 @@ export function DataGrid({
const child = data ? (typeof children === 'function' ? children(data) : children) : null;
return (
<Column gap="4" minHeight="300px">
{allowSearch && (
<Row alignItems="center" justifyContent="space-between" wrap="wrap" gap>
<SearchField
value={search}
onSearch={handleSearch}
delay={searchDelay || DEFAULT_SEARCH_DELAY}
autoFocus={autoFocus}
placeholder={t(labels.search)}
/>
{renderActions?.()}
</Row>
)}
<LoadingPanel
data={data?.data}
isLoading={isLoading}
isFetching={isFetching}
error={error}
renderEmpty={renderEmpty}
>
{data && (
<>
<Column gap="4" minHeight="300px" justifyContent="space-between">
<Column gap="4">
{allowSearch && (
<Row alignItems="center" justifyContent="space-between" wrap="wrap" gap>
<SearchField
value={search}
onSearch={handleSearch}
delay={searchDelay || DEFAULT_SEARCH_DELAY}
autoFocus={autoFocus}
placeholder={t(labels.search)}
/>
{renderActions?.()}
</Row>
)}
<LoadingPanel
data={data?.data}
isLoading={isLoading}
isFetching={isFetching}
error={error}
renderEmpty={renderEmpty}
>
{data && (
<Column>
{isValidElement(child)
? cloneElement(child as ReactElement<any>, { displayMode })
: child}
</Column>
{showPager && (
<Row marginTop="6">
<Pager
page={data.page}
pageSize={data.pageSize}
count={data.count}
isCapped={data.isCapped}
onPageChange={handlePageChange}
/>
</Row>
)}
</>
)}
</LoadingPanel>
)}
</LoadingPanel>
</Column>
{showPager && (
<Row marginTop="4">
<Pager
page={data.page}
pageSize={data.pageSize}
count={data.count}
isCapped={data.isCapped}
onPageChange={handlePageChange}
/>
</Row>
)}
</Column>
);
}
+39 -26
View File
@@ -11,11 +11,18 @@ export interface PagerProps {
className?: string;
}
export function Pager({ page, pageSize, count, isCapped, onPageChange }: PagerProps) {
export function Pager({
page,
pageSize,
count,
isCapped,
onPageChange,
}: PagerProps) {
const { t, labels } = useMessages();
const maxPage = pageSize && count ? Math.ceil(+count / +pageSize) : 0;
const lastPage = page === maxPage;
const firstPage = page === 1;
const showNavigation = maxPage > 1 || isCapped;
if (count === 0 || !maxPage) {
return null;
@@ -29,34 +36,40 @@ export function Pager({ page, pageSize, count, isCapped, onPageChange }: PagerPr
}
};
if (maxPage === 1 && !isCapped) {
return null;
}
const displayCount = isCapped ? `10,000+` : (+count).toLocaleString();
return (
<Row alignItems="center" justifyContent="space-between" gap="3" flexGrow={1}>
<Text>{t(labels.numberOfRecords, { x: displayCount })}</Text>
<Row alignItems="center" justifyContent="flex-end" gap="3">
<Text>
{t(labels.pageOf, {
current: page.toLocaleString(),
total: maxPage.toLocaleString(),
})}
</Text>
<Row gap="1">
<Button variant="outline" onPress={() => handlePageChange(-1)} isDisabled={firstPage}>
<Icon size="sm" rotate={180}>
<ChevronRight />
</Icon>
</Button>
<Button variant="outline" onPress={() => handlePageChange(1)} isDisabled={lastPage}>
<Icon size="sm">
<ChevronRight />
</Icon>
</Button>
</Row>
<Row alignItems="center" justifyContent="space-between" gap="3" flexGrow={1} wrap="wrap">
<Text color="muted">{t(labels.numberOfRecords, { x: displayCount })}</Text>
<Row
alignItems="center"
justifyContent="flex-end"
gap="3"
wrap="nowrap"
style={{ whiteSpace: 'nowrap' }}
>
{showNavigation && (
<>
<Text color="muted">
{t(labels.pageOf, {
current: page.toLocaleString(),
total: maxPage.toLocaleString(),
})}
</Text>
<Row gap="1">
<Button variant="outline" onPress={() => handlePageChange(-1)} isDisabled={firstPage}>
<Icon size="sm" rotate={180}>
<ChevronRight />
</Icon>
</Button>
<Button variant="outline" onPress={() => handlePageChange(1)} isDisabled={lastPage}>
<Icon size="sm">
<ChevronRight />
</Icon>
</Button>
</Row>
</>
)}
</Row>
</Row>
);
+94
View File
@@ -0,0 +1,94 @@
'use client';
import type { ReactNode } from 'react';
import { Icon } from '@umami/react-zen';
import { ChevronDown, ChevronUp } from '@/components/icons';
import { useNavigation } from '@/components/hooks';
type SortDirection = 'asc' | 'desc';
export interface SortableLabelProps {
label: ReactNode;
sortKey: string;
defaultDirection?: SortDirection;
}
export function SortableLabel({
label,
sortKey,
defaultDirection = 'asc',
}: SortableLabelProps) {
const { router, query, updateParams } = useNavigation();
const isActive = query.orderBy === sortKey;
const isDescending = query.sortDescending === 'true';
const direction = isActive ? (isDescending ? 'desc' : 'asc') : undefined;
const activeColor = 'var(--text-primary)';
const getNextDirection = (): SortDirection => {
if (!isActive) {
return defaultDirection;
}
return direction === 'desc' ? 'asc' : 'desc';
};
const handleSort = () => {
const nextDirection = getNextDirection();
router.push(
updateParams({
orderBy: sortKey,
sortDescending: nextDirection === 'desc' ? 'true' : undefined,
page: 1,
}),
);
};
return (
<button
type="button"
onClick={handleSort}
className="inline-flex appearance-none items-center gap-1 border-0 bg-transparent p-0 text-inherit"
aria-pressed={isActive}
>
<span>{label}</span>
<span
aria-hidden
style={{
display: 'inline-flex',
flexDirection: 'column',
gap: 0,
lineHeight: 0,
color: 'var(--text-muted)',
opacity: 0.8,
}}
>
<span
style={{
color: direction === 'asc' ? activeColor : undefined,
opacity: direction === 'asc' ? 1 : 0.55,
transform: 'scale(0.9)',
transformOrigin: 'center',
}}
>
<Icon size="sm" color={direction === 'asc' ? undefined : 'muted'}>
<ChevronUp />
</Icon>
</span>
<span
style={{
color: direction === 'desc' ? activeColor : undefined,
opacity: direction === 'desc' ? 1 : 0.55,
marginTop: '-6px',
transform: 'scale(0.9)',
transformOrigin: 'center',
}}
>
<Icon size="sm" color={direction === 'desc' ? undefined : 'muted'}>
<ChevronDown />
</Icon>
</span>
</span>
</button>
);
}
@@ -1,14 +1,15 @@
import { useApi } from '../useApi';
import { useModified } from '../useModified';
import { usePagedQuery } from '../usePagedQuery';
export function useUserTeamsQuery(userId: string) {
const { get, useQuery } = useApi();
const { get } = useApi();
const { modified } = useModified(`teams`);
return useQuery({
return usePagedQuery({
queryKey: ['teams', { userId, modified }],
queryFn: () => {
return get(`/users/${userId}/teams`);
queryFn: params => {
return get(`/users/${userId}/teams`, params);
},
enabled: !!userId,
});
+14 -9
View File
@@ -1,27 +1,32 @@
import { useMemo } from 'react';
import { FILTER_COLUMNS } from '@/lib/constants';
import { useShare } from './context/useShare';
import { useNavigation } from './useNavigation';
export function useFilterParameters({ includePagination = true }: { includePagination?: boolean } = {}) {
const { query } = useNavigation();
const share = useShare();
const allowFilter = share?.parameters?.allowFilter !== false;
return useMemo(() => {
const filterParams: Record<string, any> = {};
for (const key of Object.keys(query)) {
const baseName = key.replace(/\d+$/, '');
if (FILTER_COLUMNS[baseName]) {
filterParams[key] = query[key];
if (allowFilter) {
for (const key of Object.keys(query)) {
const baseName = key.replace(/\d+$/, '');
if (FILTER_COLUMNS[baseName]) {
filterParams[key] = query[key];
}
}
}
const params = {
...filterParams,
search: query.search,
segment: query.segment,
cohort: query.cohort,
excludeBounce: query.excludeBounce,
match: query.match,
segment: allowFilter ? query.segment : undefined,
cohort: allowFilter ? query.cohort : undefined,
excludeBounce: allowFilter ? query.excludeBounce : undefined,
match: allowFilter ? query.match : undefined,
} as Record<string, any>;
if (includePagination) {
@@ -30,5 +35,5 @@ export function useFilterParameters({ includePagination = true }: { includePagin
}
return params;
}, [query, includePagination]);
}, [allowFilter, includePagination, query]);
}
+26 -21
View File
@@ -1,5 +1,6 @@
import { FILTER_COLUMNS, OPERATORS } from '@/lib/constants';
import { safeDecodeURIComponent } from '@/lib/url';
import { useShare } from './context/useShare';
import { useFields } from './useFields';
import { useMessages } from './useMessages';
import { useNavigation } from './useNavigation';
@@ -10,6 +11,8 @@ export function useFilters() {
const { query } = useNavigation();
const { fields } = useFields();
const operatorLabels = useOperatorLabels();
const share = useShare();
const allowFilter = share?.parameters?.allowFilter !== false;
const operators = [
{ name: 'eq', type: 'string', label: t(labels.is) },
@@ -56,30 +59,32 @@ export function useFilters() {
uuid: [OPERATORS.equals],
};
const filters = Object.keys(query).reduce((arr, key) => {
const baseName = key.replace(/\d+$/, '');
if (FILTER_COLUMNS[baseName]) {
let operator = 'eq';
let value = safeDecodeURIComponent(query[key]);
const label = fields.find(({ name }) => name === baseName)?.label;
const filters = allowFilter
? Object.keys(query).reduce((arr, key) => {
const baseName = key.replace(/\d+$/, '');
if (FILTER_COLUMNS[baseName]) {
let operator = 'eq';
let value = safeDecodeURIComponent(query[key]);
const label = fields.find(({ name }) => name === baseName)?.label;
const match = value.match(/^([a-z]+)\.(.*)/);
const match = value.match(/^([a-z]+)\.(.*)/);
if (match) {
operator = match[1];
value = match[2];
}
if (match) {
operator = match[1];
value = match[2];
}
return arr.concat({
name: key,
type: baseName,
operator,
value,
label,
});
}
return arr;
}, []);
return arr.concat({
name: key,
type: baseName,
operator,
value,
label,
});
}
return arr;
}, [])
: [];
const getFilters = (type: string) => {
return (
+4 -2
View File
@@ -3,7 +3,7 @@ import { useNavigation } from './useNavigation';
export function usePageParameters() {
const {
query: { page, pageSize, search },
query: { page, pageSize, search, orderBy, sortDescending },
} = useNavigation();
return useMemo(() => {
@@ -11,6 +11,8 @@ export function usePageParameters() {
page,
pageSize,
search,
orderBy,
sortDescending,
};
}, [page, pageSize, search]);
}, [orderBy, page, pageSize, search, sortDescending]);
}
+3 -3
View File
@@ -15,13 +15,13 @@ export function usePagedQuery<TData = any, TError = Error>({
queryFn: (params?: object) => Promise<PageResult<TData>> | PageResult<TData>;
}): UseQueryResult<PageResult<TData>, TError> {
const {
query: { page, search },
query: { page, search, orderBy, sortDescending },
} = useNavigation();
const { useQuery } = useApi();
return useQuery<PageResult<TData>, TError>({
queryKey: [...queryKey, page, search] as const,
queryFn: () => queryFn({ page, search }),
queryKey: [...queryKey, page, search, orderBy, sortDescending] as const,
queryFn: () => queryFn({ page, search, orderBy, sortDescending }),
...options,
});
}
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react';
import { Button, Icon, Row } from '@umami/react-zen';
import { Monitor, Moon, Sun } from '@/components/icons';
export type ThemeMode = 'light' | 'dark' | 'system';
export function ThemeModeSelector({
value,
onChange,
includeSystem = false,
}: {
value?: ThemeMode;
onChange: (value: ThemeMode) => void;
includeSystem?: boolean;
}) {
const options: { id: ThemeMode; icon: ReactNode; label: string }[] = [
{ id: 'light', icon: <Sun />, label: 'Light' },
...(includeSystem ? [{ id: 'system' as const, icon: <Monitor />, label: 'System' }] : []),
{ id: 'dark', icon: <Moon />, label: 'Dark' },
];
return (
<Row gap>
{options.map(({ id, icon, label }) => (
<Button
key={id}
variant={value === id ? 'primary' : undefined}
onPress={() => onChange(id)}
aria-label={label}
>
<Icon>{icon}</Icon>
</Button>
))}
</Row>
);
}
+1
View File
@@ -190,6 +190,7 @@ export const labels: Record<string, string> = {
type: 'label.type',
filter: 'label.filter',
filters: 'label.filters',
filtersEnabled: 'label.filters-enabled',
propertyFilter: 'label.property-filter',
activityByProperty: 'label.activity-by-property',
breakdown: 'label.breakdown',

Some files were not shown because too many files have changed in this diff Show More