feat(domain): centralize data definition
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@basango/domain": "workspace:*",
|
||||
"@basango/encryption": "workspace:*",
|
||||
"@basango/logger": "workspace:*",
|
||||
"@date-fns/utc": "^2.1.1",
|
||||
@@ -23,6 +24,9 @@
|
||||
"./schema": "./src/schema.ts",
|
||||
"./utils": "./src/utils/index.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#db/*": "./src/*"
|
||||
},
|
||||
"name": "@basango/db",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* Base URL for source images.
|
||||
* This URL is used to construct the full path to source images stored on the server.
|
||||
*/
|
||||
export const SOURCE_IMAGE_BASE = "https://devscast.org/images/sources/";
|
||||
|
||||
/**
|
||||
* Number of days to include in the publication graph for sources.
|
||||
* This defines the time range for which publication data is aggregated and displayed.
|
||||
*/
|
||||
export const PUBLICATION_GRAPH_DAYS = 30;
|
||||
|
||||
/**
|
||||
* Maximum number of category shares to return for a source.
|
||||
* This limits the number of categories displayed in the category share breakdown.
|
||||
*/
|
||||
export const CATEGORY_SHARES_LIMIT = 10;
|
||||
|
||||
/**
|
||||
* The default timezone
|
||||
*/
|
||||
export const TIMEZONE = "Africa/Lubumbashi";
|
||||
|
||||
/**
|
||||
* Default pagination settings.
|
||||
* These constants define the default page number, default limit per page,
|
||||
* and maximum limit allowed for paginated requests.
|
||||
*/
|
||||
export const PAGINATION_DEFAULT_PAGE = 1;
|
||||
export const PAGINATION_DEFAULT_LIMIT = 5;
|
||||
export const PAGINATION_MAX_LIMIT = 100;
|
||||
@@ -1,26 +1,45 @@
|
||||
import { DEFAULT_TIMEZONE } from "@basango/domain/constants";
|
||||
import {
|
||||
Distribution,
|
||||
Distributions,
|
||||
ID,
|
||||
PaginationState,
|
||||
Publication,
|
||||
Publications,
|
||||
Sentiment,
|
||||
} from "@basango/domain/models";
|
||||
import { md5 } from "@basango/encryption";
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { count, desc, eq, getTableColumns, sql } from "drizzle-orm";
|
||||
import { v7 as uuidV7 } from "uuid";
|
||||
|
||||
import { Database } from "#db/client";
|
||||
import { getSourceIdByName } from "#db/queries/sources";
|
||||
import { ArticleMetadata, Sentiment, TokenStatistics, articles } from "#db/schema";
|
||||
import { computeReadingTime, computeTokenStatistics } from "#db/utils/computed";
|
||||
|
||||
export type CreateArticleParams = {
|
||||
title: string;
|
||||
body: string;
|
||||
categories: string[];
|
||||
link: string;
|
||||
sourceId: string;
|
||||
publishedAt: Date;
|
||||
sentiment?: Sentiment;
|
||||
tokenStatistics?: TokenStatistics;
|
||||
readingTime?: number;
|
||||
metadata?: ArticleMetadata;
|
||||
};
|
||||
import { articles, sources } from "#db/schema";
|
||||
import { CreateArticleParams, GetArticlesParams } from "#db/types/articles";
|
||||
import { GetDistributionsParams, GetPublicationsParams } from "#db/types/shared";
|
||||
import {
|
||||
applyFilters,
|
||||
buildDateRange,
|
||||
buildKeysetFilter,
|
||||
buildPaginatedResult,
|
||||
buildPaginationState,
|
||||
buildPreviousRange,
|
||||
buildSearchQuery,
|
||||
computeDelta,
|
||||
computeReadingTime,
|
||||
computeTokenStatistics,
|
||||
} from "#db/utils";
|
||||
|
||||
export async function createArticle(db: Database, params: CreateArticleParams) {
|
||||
const duplicated = await getArticleByHash(db, md5(params.link));
|
||||
if (duplicated !== undefined) {
|
||||
return {
|
||||
id: duplicated.id,
|
||||
sourceId: duplicated.sourceId,
|
||||
};
|
||||
}
|
||||
|
||||
const data = {
|
||||
...params,
|
||||
hash: md5(params.link),
|
||||
@@ -34,14 +53,6 @@ export async function createArticle(db: Database, params: CreateArticleParams) {
|
||||
}),
|
||||
};
|
||||
|
||||
const duplicated = await getArticleByHash(db, data.hash);
|
||||
if (duplicated !== undefined) {
|
||||
return {
|
||||
id: duplicated.id,
|
||||
sourceId: duplicated.sourceId,
|
||||
};
|
||||
}
|
||||
|
||||
const [result] = await db
|
||||
.insert(articles)
|
||||
.values({ id: uuidV7(), ...data })
|
||||
@@ -63,7 +74,13 @@ export async function getArticleByHash(db: Database, hash: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function countArticlesBySourceId(db: Database, sourceId: string) {
|
||||
export async function getArticleById(db: Database, id: ID) {
|
||||
return await db.query.articles.findFirst({
|
||||
where: eq(articles.id, id),
|
||||
});
|
||||
}
|
||||
|
||||
export async function countArticlesBySourceId(db: Database, sourceId: ID) {
|
||||
const result = await db
|
||||
.select({ count: count(articles.id) })
|
||||
.from(articles)
|
||||
@@ -72,3 +89,148 @@ export async function countArticlesBySourceId(db: Database, sourceId: string) {
|
||||
|
||||
return result?.count ?? 0;
|
||||
}
|
||||
|
||||
function buildFilters(params: GetArticlesParams, pagination: PaginationState) {
|
||||
const filters: SQL<unknown>[] = [];
|
||||
|
||||
if (params.sourceId) {
|
||||
filters.push(eq(articles.sourceId, params.sourceId));
|
||||
}
|
||||
|
||||
if (params.sentiment) {
|
||||
filters.push(eq(articles.sentiment, params.sentiment as Sentiment));
|
||||
}
|
||||
|
||||
if (params.category) {
|
||||
filters.push(sql`${params.category} = ANY(${articles.categories})`);
|
||||
}
|
||||
|
||||
if (params.search?.trim()) {
|
||||
const query = buildSearchQuery(params.search);
|
||||
if (query) {
|
||||
filters.push(sql`${articles.tsv} @@ to_tsquery('french', ${query})`);
|
||||
}
|
||||
}
|
||||
|
||||
const cursorFilter = buildKeysetFilter({
|
||||
cursor: pagination.payload,
|
||||
date: articles.publishedAt,
|
||||
id: articles.id,
|
||||
});
|
||||
|
||||
if (cursorFilter !== undefined) {
|
||||
filters.push(cursorFilter);
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
export async function getArticles(db: Database, params: GetArticlesParams) {
|
||||
const pagination = buildPaginationState(params);
|
||||
const filters = buildFilters(params, pagination);
|
||||
|
||||
const query = db
|
||||
.select({
|
||||
...getTableColumns(articles),
|
||||
source: {
|
||||
...getTableColumns(sources),
|
||||
},
|
||||
})
|
||||
.from(articles)
|
||||
.innerJoin(sources, eq(articles.sourceId, sources.id));
|
||||
|
||||
const rows = await applyFilters(query, filters)
|
||||
.orderBy(desc(articles.publishedAt), desc(articles.id))
|
||||
.limit(pagination.limit + 1);
|
||||
|
||||
return buildPaginatedResult(rows, pagination, {
|
||||
date: "publishedAt",
|
||||
id: "id",
|
||||
});
|
||||
}
|
||||
|
||||
export async function getArticlesPublicationGraph(
|
||||
db: Database,
|
||||
params: GetPublicationsParams,
|
||||
): Promise<Publications> {
|
||||
const [startDate, endDate] = buildDateRange(params.range);
|
||||
const [previousRangeStart, previousRangeEnd] = buildPreviousRange([startDate, endDate]);
|
||||
|
||||
const data = await db.execute<Publication>(sql`
|
||||
WITH bounds AS (
|
||||
SELECT
|
||||
${startDate}::timestamptz AS start_ts,
|
||||
${endDate}::timestamptz AS end_ts
|
||||
),
|
||||
series AS (
|
||||
SELECT (gs)::date AS d
|
||||
FROM bounds b,
|
||||
LATERAL generate_series(
|
||||
date_trunc('day', timezone(${DEFAULT_TIMEZONE}, b.start_ts)),
|
||||
date_trunc('day', timezone(${DEFAULT_TIMEZONE}, b.end_ts)),
|
||||
INTERVAL '1 day'
|
||||
) AS gs
|
||||
),
|
||||
counts AS (
|
||||
SELECT
|
||||
a.published_at::date AS d,
|
||||
COUNT(*)::int AS c
|
||||
FROM article a, bounds b
|
||||
WHERE a.published_at >= timezone(${DEFAULT_TIMEZONE}, b.start_ts)
|
||||
AND a.published_at <= timezone(${DEFAULT_TIMEZONE}, b.end_ts)
|
||||
GROUP BY 1
|
||||
)
|
||||
SELECT
|
||||
to_char(s.d, 'YYYY-MM-DD') AS date,
|
||||
COALESCE(c.c, 0) AS count
|
||||
FROM series s
|
||||
LEFT JOIN counts c USING (d)
|
||||
ORDER BY s.d ASC
|
||||
`);
|
||||
|
||||
const [previous] = await db
|
||||
.execute<{ count: number }>(
|
||||
sql`
|
||||
SELECT COALESCE(COUNT(*)::int, 0) AS count
|
||||
FROM article a
|
||||
WHERE a.published_at >= timezone(${DEFAULT_TIMEZONE}, ${previousRangeStart})
|
||||
AND a.published_at <= timezone(${DEFAULT_TIMEZONE}, ${previousRangeEnd})
|
||||
`,
|
||||
)
|
||||
.then((res) => res.rows);
|
||||
|
||||
const currentTotal = data.rows.reduce((acc, item) => acc + item.count, 0);
|
||||
const previousTotal = previous?.count ?? 0;
|
||||
|
||||
return {
|
||||
items: data.rows,
|
||||
meta: {
|
||||
current: currentTotal,
|
||||
delta: computeDelta(currentTotal, previousTotal),
|
||||
previous: previousTotal,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function getArticlesSourceDistribution(
|
||||
db: Database,
|
||||
params: GetDistributionsParams,
|
||||
): Promise<Distributions> {
|
||||
const data = await db.execute<Distribution>(sql`
|
||||
SELECT
|
||||
${sources.id}::text AS id,
|
||||
${sources.name} AS name,
|
||||
COUNT(${articles.id})::int AS count,
|
||||
ROUND((COUNT(*)::numeric / SUM(COUNT(*)) OVER ()) * 100, 2)::float AS percentage
|
||||
FROM ${articles}
|
||||
JOIN ${sources} ON ${sources.id} = ${articles.sourceId}
|
||||
GROUP BY ${sources.id}, ${sources.name}
|
||||
ORDER BY count DESC
|
||||
LIMIT ${params.limit ?? 10}
|
||||
`);
|
||||
|
||||
return {
|
||||
items: data.rows,
|
||||
total: data.rows.reduce((acc, item) => acc + item.count, 0),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { endOfDay, startOfDay, subDays } from "date-fns";
|
||||
import { DEFAULT_CATEGORY_SHARES_LIMIT, DEFAULT_TIMEZONE } from "@basango/domain/constants";
|
||||
import { ID, Publication, Publications } from "@basango/domain/models";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { v7 as uuidV7 } from "uuid";
|
||||
|
||||
import { Database } from "#db/client";
|
||||
import { CATEGORY_SHARES_LIMIT, PUBLICATION_GRAPH_DAYS, TIMEZONE } from "#db/constants";
|
||||
import { NotFoundError } from "#db/errors";
|
||||
import { Credibility, articles, sources } from "#db/schema";
|
||||
import { articles, sources } from "#db/schema";
|
||||
import {
|
||||
CategoryShare,
|
||||
CategoryShares,
|
||||
GetCategorySharesParams,
|
||||
GetPublicationsParams,
|
||||
} from "#db/types/shared";
|
||||
import { CreateSourceParams, UpdateSourceParams } from "#db/types/sources";
|
||||
import { buildDateRange } from "#db/utils";
|
||||
|
||||
import { countArticlesBySourceId } from "./articles";
|
||||
|
||||
@@ -21,14 +29,6 @@ export async function getSources(db: Database) {
|
||||
);
|
||||
}
|
||||
|
||||
export type CreateSourceParams = {
|
||||
name: string;
|
||||
url: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
credibility?: Credibility;
|
||||
};
|
||||
|
||||
export async function createSource(db: Database, params: CreateSourceParams) {
|
||||
const [result] = await db
|
||||
.insert(sources)
|
||||
@@ -38,14 +38,6 @@ export async function createSource(db: Database, params: CreateSourceParams) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export type UpdateSourceParams = {
|
||||
id: string;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
credibility?: Credibility;
|
||||
};
|
||||
|
||||
export async function updateSource(db: Database, params: UpdateSourceParams) {
|
||||
const [result] = await db
|
||||
.update(sources)
|
||||
@@ -65,12 +57,8 @@ export async function updateSource(db: Database, params: UpdateSourceParams) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export type DeleteSourceParams = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export async function deleteSource(db: Database, params: DeleteSourceParams) {
|
||||
const [result] = await db.delete(sources).where(eq(sources.id, params.id)).returning();
|
||||
export async function deleteSource(db: Database, id: ID) {
|
||||
const [result] = await db.delete(sources).where(eq(sources.id, id)).returning();
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -81,7 +69,7 @@ export async function getSourceByName(db: Database, name: string) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSourceById(db: Database, id: string) {
|
||||
export async function getSourceById(db: Database, id: ID) {
|
||||
const item = await db.query.sources.findFirst({
|
||||
where: eq(sources.id, id),
|
||||
});
|
||||
@@ -108,48 +96,13 @@ export async function getSourceIdByName(db: Database, name: string): Promise<str
|
||||
return result.id;
|
||||
}
|
||||
|
||||
export type GetSourceByIdParams = {
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type PublicationEntry = {
|
||||
date: string;
|
||||
count: number;
|
||||
};
|
||||
|
||||
export type PublicationGraph = {
|
||||
items: PublicationEntry[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type CategoryShare = {
|
||||
category: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
};
|
||||
|
||||
export type CategoryShares = {
|
||||
items: CategoryShare[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type GetSourcePublicationGraphParams = {
|
||||
id: string;
|
||||
days?: number;
|
||||
range?: {
|
||||
from: Date;
|
||||
to: Date;
|
||||
};
|
||||
};
|
||||
|
||||
export async function getSourcePublicationGraph(
|
||||
db: Database,
|
||||
params: GetSourcePublicationGraphParams,
|
||||
): Promise<PublicationGraph> {
|
||||
const endDate = endOfDay(new Date());
|
||||
const startDate = startOfDay(subDays(endDate, params.days ?? PUBLICATION_GRAPH_DAYS - 1));
|
||||
params: GetPublicationsParams,
|
||||
): Promise<Publications> {
|
||||
const [startDate, endDate] = buildDateRange(params.range);
|
||||
|
||||
const data = await db.execute<PublicationEntry>(sql`
|
||||
const data = await db.execute<Publication>(sql`
|
||||
WITH bounds AS (
|
||||
SELECT
|
||||
${startDate}::timestamptz AS start_ts,
|
||||
@@ -159,8 +112,8 @@ export async function getSourcePublicationGraph(
|
||||
SELECT (gs)::date AS d
|
||||
FROM bounds b,
|
||||
LATERAL generate_series(
|
||||
date_trunc('day', timezone(${TIMEZONE}, b.start_ts)),
|
||||
date_trunc('day', timezone(${TIMEZONE}, b.end_ts)),
|
||||
date_trunc('day', timezone(${DEFAULT_TIMEZONE}, b.start_ts)),
|
||||
date_trunc('day', timezone(${DEFAULT_TIMEZONE}, b.end_ts)),
|
||||
INTERVAL '1 day'
|
||||
) AS gs
|
||||
),
|
||||
@@ -170,8 +123,8 @@ export async function getSourcePublicationGraph(
|
||||
COUNT(*)::int AS c
|
||||
FROM article a, bounds b
|
||||
WHERE a.source_id = ${params.id}::uuid
|
||||
AND a.published_at >= timezone(${TIMEZONE}, b.start_ts)
|
||||
AND a.published_at <= timezone(${TIMEZONE}, b.end_ts)
|
||||
AND a.published_at >= timezone(${DEFAULT_TIMEZONE}, b.start_ts)
|
||||
AND a.published_at <= timezone(${DEFAULT_TIMEZONE}, b.end_ts)
|
||||
GROUP BY 1
|
||||
)
|
||||
SELECT
|
||||
@@ -182,17 +135,12 @@ export async function getSourcePublicationGraph(
|
||||
ORDER BY s.d ASC
|
||||
`);
|
||||
|
||||
return { items: data.rows, total: data.rows.length };
|
||||
return { items: data.rows };
|
||||
}
|
||||
|
||||
export type GetSourceCategorySharesParams = {
|
||||
id: string;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export async function getSourceCategoryShares(
|
||||
db: Database,
|
||||
params: GetSourceCategorySharesParams,
|
||||
params: GetCategorySharesParams,
|
||||
): Promise<CategoryShares> {
|
||||
const data = await db.execute<CategoryShare>(sql`
|
||||
SELECT
|
||||
@@ -208,7 +156,7 @@ export async function getSourceCategoryShares(
|
||||
WHERE cat IS NOT NULL
|
||||
GROUP BY cat
|
||||
ORDER BY count DESC
|
||||
LIMIT ${params.limit ?? CATEGORY_SHARES_LIMIT}
|
||||
LIMIT ${params.limit ?? DEFAULT_CATEGORY_SHARES_LIMIT}
|
||||
`);
|
||||
|
||||
return { items: data.rows, total: data.rowCount ?? 0 };
|
||||
|
||||
+16
-83
@@ -1,3 +1,12 @@
|
||||
import { BIAS, RELIABILITY, SENTIMENT, TRANSPARENCY } from "@basango/domain/constants";
|
||||
import {
|
||||
ArticleMetadata,
|
||||
Credibility,
|
||||
Device,
|
||||
GeoLocation,
|
||||
Roles,
|
||||
TokenStatistics,
|
||||
} from "@basango/domain/models";
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import { check } from "drizzle-orm/gel-core";
|
||||
import {
|
||||
@@ -22,100 +31,24 @@ import {
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const tsvector = customType<{ data: string; driverData: string }>({
|
||||
const tsvector = customType<{ data: string; driverData: string }>({
|
||||
dataType() {
|
||||
return "tsvector";
|
||||
},
|
||||
});
|
||||
|
||||
export const customJsonType = <T>() =>
|
||||
customType<{ data: T }>({
|
||||
dataType() {
|
||||
return "jsonb";
|
||||
},
|
||||
fromDriver(value) {
|
||||
return value as T;
|
||||
},
|
||||
toDriver(value) {
|
||||
return value; // JSONB → just pass the object
|
||||
},
|
||||
});
|
||||
pgEnum("bias", BIAS);
|
||||
pgEnum("reliability", RELIABILITY);
|
||||
pgEnum("transparency", TRANSPARENCY);
|
||||
|
||||
export const biasEnum = pgEnum("bias", ["neutral", "slightly", "partisan", "extreme"]);
|
||||
export const reliabilityEnum = pgEnum("reliability", [
|
||||
"trusted",
|
||||
"reliable",
|
||||
"average",
|
||||
"low_trust",
|
||||
"unreliable",
|
||||
]);
|
||||
export const sentimentEnum = pgEnum("sentiment", ["positive", "neutral", "negative"]);
|
||||
export const transparencyEnum = pgEnum("transparency", ["high", "medium", "low"]);
|
||||
export const tokenPurposeEnum = pgEnum("token_purpose", [
|
||||
const sentimentEnum = pgEnum("sentiment", SENTIMENT);
|
||||
const tokenPurposeEnum = pgEnum("token_purpose", [
|
||||
"confirm_account",
|
||||
"password_reset",
|
||||
"unlock_account",
|
||||
"delete_account",
|
||||
]);
|
||||
|
||||
export type EmailAddress = string;
|
||||
export type Link = string;
|
||||
export type ReadingTime = number;
|
||||
|
||||
export type Role = "ROLE_USER" | "ROLE_ADMIN";
|
||||
export type Roles = Role[];
|
||||
|
||||
export type Bias = (typeof biasEnum.enumValues)[number];
|
||||
export type Reliability = (typeof reliabilityEnum.enumValues)[number];
|
||||
export type Sentiment = (typeof sentimentEnum.enumValues)[number];
|
||||
export type Transparency = (typeof transparencyEnum.enumValues)[number];
|
||||
export type TokenPurpose = (typeof tokenPurposeEnum.enumValues)[number];
|
||||
|
||||
export type Credibility = {
|
||||
bias: Bias;
|
||||
reliability: Reliability;
|
||||
transparency: Transparency;
|
||||
};
|
||||
|
||||
export type TokenStatistics = {
|
||||
title: number;
|
||||
body: number;
|
||||
categories: number;
|
||||
excerpt: number;
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type Device = {
|
||||
operatingSystem?: string;
|
||||
client?: string;
|
||||
device?: string;
|
||||
isBot: boolean;
|
||||
};
|
||||
|
||||
export type GeoLocation = {
|
||||
country?: string;
|
||||
city?: string;
|
||||
timeZone?: string;
|
||||
longitude?: number;
|
||||
latitude?: number;
|
||||
accuracyRadius?: number;
|
||||
};
|
||||
|
||||
export type ArticleMetadata = {
|
||||
title?: string;
|
||||
description?: string;
|
||||
image?: string;
|
||||
};
|
||||
|
||||
export type DateRange = {
|
||||
start: number; // unix timestamp (seconds)
|
||||
end: number; // unix timestamp (seconds)
|
||||
};
|
||||
|
||||
// Secrets
|
||||
export type GeneratedToken = string;
|
||||
export type GeneratedCode = string;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Tables */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -124,7 +57,7 @@ export const users = pgTable(
|
||||
"user",
|
||||
{
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
email: varchar({ length: 255 }).$type<EmailAddress>().notNull(),
|
||||
email: varchar({ length: 255 }).notNull(),
|
||||
id: uuid().primaryKey().notNull(),
|
||||
isConfirmed: boolean("is_confirmed").default(false).notNull(),
|
||||
isLocked: boolean("is_locked").default(false).notNull(),
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ArticleMetadata, ID, Sentiment, TokenStatistics } from "@basango/domain/models";
|
||||
|
||||
export type CreateArticleParams = {
|
||||
title: string;
|
||||
body: string;
|
||||
categories: string[];
|
||||
link: string;
|
||||
sourceId: string;
|
||||
publishedAt: Date;
|
||||
sentiment?: Sentiment;
|
||||
tokenStatistics?: TokenStatistics;
|
||||
readingTime?: number;
|
||||
metadata?: ArticleMetadata;
|
||||
};
|
||||
|
||||
export type GetArticleByIdParams = {
|
||||
id: ID;
|
||||
};
|
||||
|
||||
export type GetArticlesParams = {
|
||||
cursor?: string | null;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
sentiment?: Sentiment;
|
||||
sourceId?: string;
|
||||
category?: string;
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { DateRange, ID } from "@basango/domain/models";
|
||||
|
||||
export type CategoryShare = {
|
||||
category: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
};
|
||||
|
||||
export type CategoryShares = {
|
||||
items: CategoryShare[];
|
||||
total: number;
|
||||
};
|
||||
|
||||
export type GetPublicationsParams = {
|
||||
id?: ID;
|
||||
range?: DateRange;
|
||||
};
|
||||
|
||||
export type GetCategorySharesParams = {
|
||||
id: ID;
|
||||
limit?: number;
|
||||
};
|
||||
|
||||
export type GetDistributionsParams = {
|
||||
limit?: number;
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Credibility, ID } from "@basango/domain/models";
|
||||
|
||||
export type UpdateSourceParams = {
|
||||
id: ID;
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
credibility?: Credibility;
|
||||
};
|
||||
|
||||
export type CreateSourceParams = {
|
||||
name: string;
|
||||
url: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
credibility?: Credibility;
|
||||
};
|
||||
|
||||
export type GetSourceByIdParams = {
|
||||
id: ID;
|
||||
};
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Delta, TokenStatistics } from "@basango/domain/models";
|
||||
import { TiktokenEncoding, get_encoding } from "tiktoken";
|
||||
|
||||
import { TokenStatistics } from "#db/schema";
|
||||
|
||||
/**
|
||||
* Count the number of tokens in the given text using the specified encoding.
|
||||
* @param text - The input text
|
||||
@@ -57,3 +56,17 @@ export const computeReadingTime = (text: string, wordsPerMinute = 200): number =
|
||||
const words = text.trim().split(/\s+/).length;
|
||||
return Math.ceil(words / wordsPerMinute);
|
||||
};
|
||||
|
||||
export const computeDelta = (current: number, previous: number): Delta => {
|
||||
const delta = current - previous;
|
||||
const percentage = previous === 0 ? (current === 0 ? 0 : 100) : (delta / previous) * 100;
|
||||
const sign = delta >= 0 ? "+" : "-";
|
||||
const variant = previous === 0 ? "positive" : delta >= 0 ? "increase" : "decrease";
|
||||
|
||||
return {
|
||||
delta,
|
||||
percentage,
|
||||
sign,
|
||||
variant,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { DEFAULT_PUBLICATION_GRAPH_DAYS } from "@basango/domain/constants";
|
||||
import { DateRange } from "@basango/domain/models";
|
||||
import { endOfDay, startOfDay, subDays } from "date-fns";
|
||||
|
||||
export const buildSearchQuery = (input: string) => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.split(/\s+/)
|
||||
.map((term) => {
|
||||
// Escape special characters for PostgreSQL full-text search
|
||||
// Special characters: & | ! ( ) : * ' " + - ~
|
||||
const escaped = term.toLowerCase().replace(/[&|!():*'"+~-]/g, "\\$&");
|
||||
return `${escaped}:*`;
|
||||
})
|
||||
.join(" & ");
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a date range given an explicit range.
|
||||
* Defaults to the last 30 days when no range is provided.
|
||||
*/
|
||||
export function buildDateRange(range?: DateRange): [startDate: Date, endDate: Date] {
|
||||
const endDate = endOfDay(range?.end ?? new Date());
|
||||
const startDate = startOfDay(
|
||||
range?.start ?? subDays(endDate, Math.max(DEFAULT_PUBLICATION_GRAPH_DAYS - 1, 0)),
|
||||
);
|
||||
|
||||
return [startDate, endDate];
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a [start, end] date range, produce the immediately preceding range of the same length.
|
||||
*/
|
||||
export function buildPreviousRange([startDate, endDate]: [Date, Date]): [Date, Date] {
|
||||
const days = Math.max(
|
||||
1,
|
||||
Math.round((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1,
|
||||
);
|
||||
|
||||
const previousRangeEnd = endOfDay(subDays(startDate, 1));
|
||||
const previousRangeStart = startOfDay(subDays(previousRangeEnd, days - 1));
|
||||
|
||||
return [previousRangeStart, previousRangeEnd];
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from "./computed";
|
||||
export * from "./filters";
|
||||
export * from "./pagination";
|
||||
export * from "./search-query";
|
||||
|
||||
@@ -1,87 +1,96 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import {
|
||||
PAGINATION_DEFAULT_LIMIT,
|
||||
PAGINATION_DEFAULT_PAGE,
|
||||
PAGINATION_MAX_LIMIT,
|
||||
} from "#db/constants";
|
||||
DEFAULT_PAGINATION_LIMIT,
|
||||
DEFAULT_PAGINATION_MAX_LIMIT,
|
||||
DEFAULT_PAGINATION_PAGE,
|
||||
} from "@basango/domain/constants";
|
||||
import {
|
||||
PaginatedResult,
|
||||
PaginationCursor,
|
||||
PaginationRequest,
|
||||
PaginationState,
|
||||
} from "@basango/domain/models";
|
||||
import { isValid, toDate } from "date-fns";
|
||||
import { AnyColumn, SQL, and, eq, lt, or } from "drizzle-orm";
|
||||
|
||||
export interface PageRequest {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
cursor?: string | null;
|
||||
}
|
||||
|
||||
export interface PageState {
|
||||
page: number;
|
||||
limit: number;
|
||||
cursor: string | null;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface CursorPayload {
|
||||
id: string;
|
||||
date?: string | null;
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
current: number;
|
||||
limit: number;
|
||||
cursor: string | null;
|
||||
hasNext: boolean;
|
||||
}
|
||||
|
||||
export function createPageState(request: PageRequest = {}): PageState {
|
||||
export function buildPaginationState(request: PaginationRequest = {}): PaginationState {
|
||||
const page =
|
||||
Number.isFinite(request.page) && (request.page ?? 0) > 0
|
||||
? Math.trunc(request.page!)
|
||||
: PAGINATION_DEFAULT_PAGE;
|
||||
: DEFAULT_PAGINATION_PAGE;
|
||||
|
||||
let limit =
|
||||
Number.isFinite(request.limit) && (request.limit ?? 0) > 0
|
||||
? Math.trunc(request.limit!)
|
||||
: PAGINATION_DEFAULT_LIMIT;
|
||||
: DEFAULT_PAGINATION_LIMIT;
|
||||
|
||||
if (limit < PAGINATION_DEFAULT_LIMIT) {
|
||||
limit = PAGINATION_DEFAULT_LIMIT;
|
||||
if (limit < DEFAULT_PAGINATION_LIMIT) {
|
||||
limit = DEFAULT_PAGINATION_LIMIT;
|
||||
}
|
||||
|
||||
if (limit > PAGINATION_MAX_LIMIT) {
|
||||
limit = PAGINATION_MAX_LIMIT;
|
||||
if (limit > DEFAULT_PAGINATION_MAX_LIMIT) {
|
||||
limit = DEFAULT_PAGINATION_MAX_LIMIT;
|
||||
}
|
||||
|
||||
const cursor = request.cursor ?? null;
|
||||
const payload = decodeCursor(cursor);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
return { cursor, limit, offset, page };
|
||||
return { cursor, limit, offset, page, payload };
|
||||
}
|
||||
|
||||
export function encodeCursor(
|
||||
row: Record<string, unknown>,
|
||||
keyset: { id: string; date?: string | null },
|
||||
): string {
|
||||
const payload: CursorPayload = {
|
||||
id: String(row[keyset.id] ?? ""),
|
||||
};
|
||||
export function buildPaginatedResult<T>(
|
||||
rows: T[],
|
||||
pagination: PaginationState,
|
||||
cursor: PaginationCursor,
|
||||
): PaginatedResult<T> {
|
||||
const hasNext = rows.length > pagination.limit;
|
||||
const items = rows.slice(0, pagination.limit);
|
||||
const lastItem = items[items.length - 1];
|
||||
|
||||
if (keyset.date) {
|
||||
const value = row[keyset.date];
|
||||
if (value !== undefined && value !== null) {
|
||||
payload.date = String(value);
|
||||
}
|
||||
return {
|
||||
items,
|
||||
meta: {
|
||||
current: pagination.page,
|
||||
cursor: pagination.cursor,
|
||||
hasNext,
|
||||
limit: pagination.limit,
|
||||
nextCursor: hasNext && lastItem ? encodeCursor(lastItem, cursor) : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function applyFilters(
|
||||
// biome-ignore lint/suspicious/noExplicitAny: drizzle types to be fixed
|
||||
query: any,
|
||||
filters: SQL<unknown>[],
|
||||
): typeof query {
|
||||
if (filters.length === 1) {
|
||||
return query.where(filters[0]);
|
||||
} else if (filters.length > 1) {
|
||||
return query.where(and(...filters));
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
export function encodeCursor(row: Record<string, string>, keyset: PaginationCursor): string {
|
||||
const payload: PaginationCursor = {
|
||||
date: String(row[keyset.date]),
|
||||
id: String(row[keyset.id]),
|
||||
};
|
||||
|
||||
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
|
||||
}
|
||||
|
||||
export function decodeCursor(cursor?: string | null): CursorPayload | null {
|
||||
export function decodeCursor(cursor?: string | null): PaginationCursor | null {
|
||||
if (!cursor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = Buffer.from(cursor, "base64").toString("utf8");
|
||||
const payload = JSON.parse(decoded) as CursorPayload;
|
||||
const payload = JSON.parse(decoded) as PaginationCursor;
|
||||
|
||||
if (!payload || payload.id.length === 0) {
|
||||
return null;
|
||||
@@ -92,3 +101,24 @@ export function decodeCursor(cursor?: string | null): CursorPayload | null {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type BuildKeysetOptions = {
|
||||
cursor: PaginationCursor | null;
|
||||
date: AnyColumn;
|
||||
id: AnyColumn;
|
||||
};
|
||||
|
||||
export function buildKeysetFilter(options: BuildKeysetOptions): SQL<unknown> | undefined {
|
||||
if (!options.cursor) return undefined;
|
||||
|
||||
if (isValid(options.cursor.date)) {
|
||||
const date = toDate(options.cursor.date);
|
||||
|
||||
return or(
|
||||
lt(options.date, date),
|
||||
and(eq(options.date, date), lt(options.id, options.cursor.id)),
|
||||
);
|
||||
}
|
||||
|
||||
return lt(options.id, options.cursor.id);
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
export const buildSearchQuery = (input: string) => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return trimmed
|
||||
.split(/\s+/)
|
||||
.map((term) => {
|
||||
// Escape special characters for PostgreSQL full-text search
|
||||
// Special characters: & | ! ( ) : * ' " + - ~
|
||||
const escaped = term.toLowerCase().replace(/[&|!():*'"+~-]/g, "\\$&");
|
||||
return `${escaped}:*`;
|
||||
})
|
||||
.join(" & ");
|
||||
};
|
||||
@@ -1,10 +1,4 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"#db/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules"],
|
||||
"extends": "@basango/tsconfig/base.json",
|
||||
"include": ["src"]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"@hono/zod-openapi": "^1.1.4",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@basango/tsconfig": "workspace:*"
|
||||
},
|
||||
"exports": {
|
||||
"./constants": "./src/constants.ts",
|
||||
"./crawler": "./src/crawler/index.ts",
|
||||
"./models": "./src/models/index.ts"
|
||||
},
|
||||
"imports": {
|
||||
"#domain/*": "./src/*"
|
||||
},
|
||||
"name": "@basango/domain",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// Domain-specific constants and types
|
||||
export const BIAS = ["neutral", "slightly", "partisan", "extreme"] as const;
|
||||
export const RELIABILITY = ["trusted", "reliable", "average", "low_trust", "unreliable"] as const;
|
||||
export const TRANSPARENCY = ["high", "medium", "low"] as const;
|
||||
export const SENTIMENT = ["positive", "neutral", "negative"] as const;
|
||||
|
||||
// Crawler-related constants and types
|
||||
export const UPDATE_DIRECTIONS = ["forward", "backward"] as const;
|
||||
export const SOURCE_KINDS = ["wordpress", "html"] as const;
|
||||
|
||||
export const DEFAULT_DATE_FORMAT = "yyyy-LL-dd";
|
||||
export const DEFAULT_DATETIME_FORMAT = "yyyy-LL-ddTHH:mmZ";
|
||||
export const DEFAULT_USER_AGENT = "Basango/0.1 (+https://github.com/bernard-ng/basango)";
|
||||
export const DEFAULT_OPEN_GRAPH_USER_AGENT = "facebookexternalhit/1.1";
|
||||
export const DEFAULT_TRANSIENT_HTTP_STATUSES = [429, 500, 502, 503, 504];
|
||||
export const DEFAULT_RETRY_AFTER_HEADER = "retry-after";
|
||||
|
||||
export const DEFAULT_PAGINATION_LIMIT = 12;
|
||||
export const DEFAULT_PAGINATION_PAGE = 1;
|
||||
export const DEFAULT_PAGINATION_MAX_LIMIT = 100;
|
||||
|
||||
export const DEFAULT_SOURCE_IMAGE = "https://devscast.org/images/sources/";
|
||||
export const DEFAULT_PUBLICATION_GRAPH_DAYS = 30;
|
||||
export const DEFAULT_CATEGORY_SHARES_LIMIT = 10;
|
||||
export const DEFAULT_TIMEZONE = "Africa/Lubumbashi";
|
||||
@@ -0,0 +1,47 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { SOURCE_KINDS } from "#domain/constants";
|
||||
|
||||
// schemas
|
||||
export const SourceKindSchema = z.enum(SOURCE_KINDS);
|
||||
|
||||
export const SourceDateSchema = z.object({
|
||||
format: z.string().default("yyyy-LL-dd HH:mm"),
|
||||
});
|
||||
|
||||
const SourceConfigSchema = z.object({
|
||||
categories: z.array(z.string()).default([]),
|
||||
requiresDetails: z.boolean().default(false),
|
||||
requiresRateLimit: z.boolean().default(false),
|
||||
sourceDate: SourceDateSchema,
|
||||
sourceId: z.string(),
|
||||
sourceKind: SourceKindSchema,
|
||||
sourceUrl: z.url(),
|
||||
supportsCategories: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const HtmlSourceConfigSchema = SourceConfigSchema.extend({
|
||||
paginationTemplate: z.string(),
|
||||
sourceKind: z.literal("html"),
|
||||
sourceSelectors: z.object({
|
||||
articleBody: z.string(),
|
||||
articleCategories: z.string().optional(),
|
||||
articleDate: z.string(),
|
||||
articleLink: z.string(),
|
||||
articles: z.string(),
|
||||
articleTitle: z.string(),
|
||||
pagination: z.string().default("ul.pagination > li a"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const WordPressSourceConfigSchema = SourceConfigSchema.extend({
|
||||
sourceDate: SourceDateSchema.default(SourceDateSchema.parse({ format: "yyyy-LL-dd'T'HH:mm:ss" })),
|
||||
sourceKind: z.literal("wordpress"),
|
||||
});
|
||||
|
||||
// types
|
||||
export type SourceKind = z.infer<typeof SourceKindSchema>;
|
||||
export type SourceDate = z.infer<typeof SourceDateSchema>;
|
||||
export type HtmlSourceConfig = z.infer<typeof HtmlSourceConfigSchema>;
|
||||
export type WordPressSourceConfig = z.infer<typeof WordPressSourceConfigSchema>;
|
||||
export type AnySourceConfig = HtmlSourceConfig | WordPressSourceConfig;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./config";
|
||||
export * from "./schemas";
|
||||
@@ -0,0 +1,66 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { UPDATE_DIRECTIONS } from "#domain/constants";
|
||||
|
||||
// schemas
|
||||
export const UpdateDirectionSchema = z.enum(UPDATE_DIRECTIONS);
|
||||
|
||||
export const TimestampRangeSchema = z
|
||||
.object({
|
||||
end: z.number().int(),
|
||||
start: z.number().int(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.start === 0 || value.end === 0) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "Timestamp cannot be zero",
|
||||
});
|
||||
}
|
||||
if (value.end < value.start) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "End timestamp must be greater than or equal to start",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const PageRangeSchema = z
|
||||
.object({
|
||||
end: z.number().int().min(0),
|
||||
start: z.number().int().min(0),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.end < value.start) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
message: "End page must be greater than or equal to start page",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const PageSpecSchema = z
|
||||
.string()
|
||||
.regex(/^[0-9]+:[0-9]+$/, "Invalid page range format. Use start:end")
|
||||
.transform((spec) => {
|
||||
const [startText, endText] = spec.split(":");
|
||||
return {
|
||||
end: Number.parseInt(String(endText), 10),
|
||||
start: Number.parseInt(String(startText), 10),
|
||||
};
|
||||
});
|
||||
|
||||
export const DateSpecSchema = z
|
||||
.string()
|
||||
.regex(/.+:.+/, "Expected start:end format")
|
||||
.transform((spec) => {
|
||||
const [startRaw, endRaw] = spec.split(":");
|
||||
return { endRaw: String(endRaw), startRaw: String(startRaw) };
|
||||
});
|
||||
|
||||
// types
|
||||
export type UpdateDirection = z.infer<typeof UpdateDirectionSchema>;
|
||||
export type TimestampRange = z.infer<typeof TimestampRangeSchema>;
|
||||
export type PageSpec = z.infer<typeof PageSpecSchema>;
|
||||
export type DateSpec = z.infer<typeof DateSpecSchema>;
|
||||
export type PageRange = z.infer<typeof PageRangeSchema>;
|
||||
@@ -0,0 +1,175 @@
|
||||
import { z } from "@hono/zod-openapi";
|
||||
|
||||
import { idSchema, sentimentSchema } from "#domain/models/shared";
|
||||
|
||||
// schemas
|
||||
export const articleMetadataSchema = z.object({
|
||||
author: z.string().optional().openapi({
|
||||
description: "The author of the article.",
|
||||
example: "John Doe",
|
||||
}),
|
||||
description: z.string().optional().openapi({
|
||||
description: "A brief description or summary of the article.",
|
||||
example: "This article discusses the latest advancements in AI technology.",
|
||||
}),
|
||||
image: z.url().optional().openapi({
|
||||
description: "The URL of the main image associated with the article.",
|
||||
example: "https://example.com/image.jpg",
|
||||
}),
|
||||
publishedAt: z.date().optional().openapi({
|
||||
description: "The publication date of the article as a Date object.",
|
||||
example: "2023-01-01T00:00:00Z",
|
||||
}),
|
||||
title: z.string().optional().openapi({
|
||||
description: "The title of the article for metadata purposes.",
|
||||
example: "The Rise of AI",
|
||||
}),
|
||||
updatedAt: z.date().optional().openapi({
|
||||
description: "The last updated date of the article as a Date object.",
|
||||
example: "2023-01-02T12:00:00Z",
|
||||
}),
|
||||
url: z.url().optional().openapi({
|
||||
description: "The canonical URL of the article.",
|
||||
example: "https://example.com/article",
|
||||
}),
|
||||
});
|
||||
|
||||
export const tokenStatisticsSchema = z.object({
|
||||
body: z.number().optional().default(0).openapi({
|
||||
description: "The number of tokens in the article body.",
|
||||
example: 250,
|
||||
}),
|
||||
categories: z.number().optional().default(0).openapi({
|
||||
description: "The number of tokens in the article categories.",
|
||||
example: 3,
|
||||
}),
|
||||
excerpt: z.number().optional().default(0).openapi({
|
||||
description: "The number of tokens in the article excerpt.",
|
||||
example: 50,
|
||||
}),
|
||||
title: z.number().optional().default(0).openapi({
|
||||
description: "The number of tokens in the article title.",
|
||||
example: 10,
|
||||
}),
|
||||
total: z.number().optional().default(0).openapi({
|
||||
description: "The total number of tokens in the article.",
|
||||
example: 313,
|
||||
}),
|
||||
});
|
||||
|
||||
export const articleSchema = z.object({
|
||||
body: z.string().min(1).openapi({
|
||||
description: "The main content of the article.",
|
||||
example: "This is the body of the article...",
|
||||
}),
|
||||
categories: z.array(z.string()).openapi({
|
||||
description: "The categories or tags associated with the article.",
|
||||
example: ["Technology", "AI"],
|
||||
}),
|
||||
createdAt: z.date().openapi({
|
||||
description: "The date and time when the article was created in the system.",
|
||||
example: "2023-01-01T12:00:00Z",
|
||||
}),
|
||||
hash: z.string().min(1).openapi({
|
||||
description: "The unique hash of the article link.",
|
||||
example: "d41d8cd98f00b204e9800998ecf8427e",
|
||||
}),
|
||||
id: idSchema,
|
||||
link: z.string().url().openapi({
|
||||
description: "The URL of the article.",
|
||||
example: "https://example.com/article",
|
||||
}),
|
||||
metadata: articleMetadataSchema.optional(),
|
||||
publishedAt: z.date().openapi({
|
||||
description: "The publication date of the article as a Date object.",
|
||||
example: "2023-01-01T00:00:00Z",
|
||||
}),
|
||||
sourceId: z.union([z.uuid(), z.string().min(1)]).openapi({
|
||||
description: "The unique identifier of the source from which the article was crawled.",
|
||||
example: "b3e1c8f4-5d6a-4c9e-8f1e-2d3c4b5a6f7g",
|
||||
}),
|
||||
title: z.string().min(1).openapi({
|
||||
description: "The title of the article.",
|
||||
example: "The Rise of AI",
|
||||
}),
|
||||
tokenStatistics: tokenStatisticsSchema.optional(),
|
||||
updatedAt: z.date().optional().openapi({
|
||||
description: "The date and time when the article was last updated in the system.",
|
||||
example: "2023-01-02T12:00:00Z",
|
||||
}),
|
||||
});
|
||||
|
||||
// API
|
||||
export const createArticleSchema = z
|
||||
.object({
|
||||
body: z.string().min(1).openapi({
|
||||
description: "The main content of the article.",
|
||||
example: "This is the body of the article...",
|
||||
}),
|
||||
categories: z
|
||||
.array(z.string())
|
||||
.openapi({
|
||||
description: "The categories or tags associated with the article.",
|
||||
example: ["Technology", "AI"],
|
||||
})
|
||||
.optional()
|
||||
.default([]),
|
||||
hash: z.string().min(1).openapi({
|
||||
description: "The unique hash of the article link.",
|
||||
example: "d41d8cd98f00b204e9800998ecf8427e",
|
||||
}),
|
||||
link: z.string().url().openapi({
|
||||
description: "The URL of the article.",
|
||||
example: "https://example.com/article",
|
||||
}),
|
||||
metadata: articleMetadataSchema.optional(),
|
||||
publishedAt: z
|
||||
.string()
|
||||
.refine((value) => !Number.isNaN(Date.parse(value)), {
|
||||
message: "Invalid date format",
|
||||
})
|
||||
.transform((value) => new Date(value))
|
||||
.openapi({
|
||||
description: "The publication date of the article in ISO 8601 format.",
|
||||
example: "2023-01-01T00:00:00Z",
|
||||
}),
|
||||
sourceId: z.string().openapi({
|
||||
description: "The unique identifier of the source from which the article was crawled.",
|
||||
example: "radiookapi.net",
|
||||
}),
|
||||
title: z.string().min(1).openapi({
|
||||
description: "The title of the article.",
|
||||
example: "The Rise of AI",
|
||||
}),
|
||||
})
|
||||
.openapi("CreateArticle");
|
||||
|
||||
export const createArticleResponseSchema = z
|
||||
.object({ id: idSchema, sourceId: idSchema })
|
||||
.openapi("CreateArticleResponse");
|
||||
|
||||
export const getArticlesSchema = z.object({
|
||||
category: z.string().min(1).max(255).optional().openapi({
|
||||
description: "Filter articles by a specific category.",
|
||||
example: "Technology",
|
||||
}),
|
||||
cursor: z.string().nullable().optional().openapi({
|
||||
description: "Optional cursor for fetching the next page of articles.",
|
||||
}),
|
||||
limit: z.number().int().min(1).max(100).optional().openapi({
|
||||
default: 10,
|
||||
description: "Maximum number of articles to return per page.",
|
||||
example: 20,
|
||||
}),
|
||||
search: z.string().max(512).optional().openapi({
|
||||
description: "Full-text search query applied to article titles and bodies.",
|
||||
example: "gouvernement congolais",
|
||||
}),
|
||||
sentiment: sentimentSchema.optional(),
|
||||
sourceId: idSchema.optional(),
|
||||
});
|
||||
|
||||
// types
|
||||
export type Article = z.infer<typeof articleSchema>;
|
||||
export type ArticleMetadata = z.infer<typeof articleMetadataSchema>;
|
||||
export type TokenStatistics = z.infer<typeof tokenStatisticsSchema>;
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./articles";
|
||||
export * from "./shared";
|
||||
export * from "./sources";
|
||||
export * from "./users";
|
||||
@@ -0,0 +1,339 @@
|
||||
import { z } from "@hono/zod-openapi";
|
||||
|
||||
import { BIAS, RELIABILITY, SENTIMENT, TRANSPARENCY } from "#domain/constants";
|
||||
|
||||
// schemas
|
||||
export const idSchema = z.uuid().openapi({
|
||||
description: "The unique identifier of the resource.",
|
||||
example: "b3e1c8f4-5d6a-4c9e-8f1e-2d3c4b5a6f7g",
|
||||
});
|
||||
|
||||
export const dateRangeSchema = z
|
||||
.object({
|
||||
end: z.date().openapi({
|
||||
description: "The end date of the range.",
|
||||
example: "2023-01-30T23:59:59Z",
|
||||
}),
|
||||
start: z.date().openapi({
|
||||
description: "The start date of the range.",
|
||||
example: "2023-01-01T00:00:00Z",
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
description: "Inclusive date range for publication metrics.",
|
||||
});
|
||||
|
||||
export const limitSchema = z.number().int().min(1).max(100).openapi({
|
||||
default: 10,
|
||||
description: "The maximum number of items to return.",
|
||||
example: 10,
|
||||
});
|
||||
|
||||
export const sentimentSchema = z.enum(SENTIMENT).openapi({
|
||||
description: "Sentiment detected for the article.",
|
||||
example: "positive",
|
||||
});
|
||||
|
||||
export const biasSchema = z.enum(BIAS).openapi({
|
||||
description: "The bias level of the source.",
|
||||
example: "neutral",
|
||||
});
|
||||
|
||||
export const reliabilitySchema = z.enum(RELIABILITY).openapi({
|
||||
description: "The reliability level of the source.",
|
||||
example: "trusted",
|
||||
});
|
||||
|
||||
export const transparencySchema = z.enum(TRANSPARENCY).openapi({
|
||||
description: "The transparency level of the source.",
|
||||
example: "high",
|
||||
});
|
||||
|
||||
export const credibilitySchema = z
|
||||
.object({
|
||||
bias: biasSchema.default("neutral"),
|
||||
reliability: reliabilitySchema.default("average"),
|
||||
transparency: transparencySchema.default("medium"),
|
||||
})
|
||||
.openapi({
|
||||
description: "Credibility information about the resource.",
|
||||
});
|
||||
|
||||
export const deviceSchema = z
|
||||
.object({
|
||||
client: z.string().optional().openapi({
|
||||
description: "The client software of the device.",
|
||||
example: "Chrome 90",
|
||||
}),
|
||||
device: z.string().optional().openapi({
|
||||
description: "The device model.",
|
||||
example: "Dell XPS 13",
|
||||
}),
|
||||
isBot: z.boolean().openapi({
|
||||
description: "Indicates if the device is a bot.",
|
||||
example: false,
|
||||
}),
|
||||
operatingSystem: z.string().optional().openapi({
|
||||
description: "The operating system of the device.",
|
||||
example: "Windows 10",
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
description: "Information about the user's device.",
|
||||
});
|
||||
|
||||
export const geoLocationSchema = z
|
||||
.object({
|
||||
accuracyRadius: z.number().optional().openapi({
|
||||
description: "The accuracy radius in kilometers.",
|
||||
example: 50,
|
||||
}),
|
||||
city: z.string().optional().openapi({
|
||||
description: "The city of the user.",
|
||||
example: "San Francisco",
|
||||
}),
|
||||
country: z.string().optional().openapi({
|
||||
description: "The country of the user.",
|
||||
example: "United States",
|
||||
}),
|
||||
latitude: z.number().optional().openapi({
|
||||
description: "The latitude of the user's location.",
|
||||
example: 37.7749,
|
||||
}),
|
||||
longitude: z.number().optional().openapi({
|
||||
description: "The longitude of the user's location.",
|
||||
example: -122.4194,
|
||||
}),
|
||||
timeZone: z.string().optional().openapi({
|
||||
description: "The time zone of the user.",
|
||||
example: "America/Los_Angeles",
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
description: "Geolocation information about the user.",
|
||||
});
|
||||
|
||||
export const distrubtionSchema = z
|
||||
.object({
|
||||
count: z.number().int().openapi({
|
||||
description: "The count of items in the distribution.",
|
||||
example: 42,
|
||||
}),
|
||||
id: idSchema,
|
||||
name: z.string().openapi({
|
||||
description: "The name of the distribution.",
|
||||
example: "Technology",
|
||||
}),
|
||||
percentage: z.number().openapi({
|
||||
description: "The percentage of items in the distribution.",
|
||||
example: 12.5,
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
description: "Distribution information.",
|
||||
});
|
||||
|
||||
export const getDistributionsSchema = z.object({
|
||||
id: idSchema.optional(),
|
||||
limit: limitSchema.optional(),
|
||||
});
|
||||
|
||||
export const getPublicationsSchema = z.object({
|
||||
id: idSchema.optional(),
|
||||
range: dateRangeSchema.optional(),
|
||||
});
|
||||
|
||||
export const distributionsSchema = z
|
||||
.object({
|
||||
items: z.array(distrubtionSchema).openapi({
|
||||
description: "List of distributions.",
|
||||
}),
|
||||
total: z.number().int().openapi({
|
||||
description: "Total number of distributions.",
|
||||
example: 100,
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
description: "Distributions data.",
|
||||
});
|
||||
|
||||
export const publicationSchema = z
|
||||
.object({
|
||||
count: z.number().int().openapi({
|
||||
description: "The number of articles published on that date.",
|
||||
example: 42,
|
||||
}),
|
||||
date: z.string().openapi({
|
||||
description: "The date of the publication.",
|
||||
example: "2023-01-15",
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
description: "Publication metrics for a specific date.",
|
||||
});
|
||||
|
||||
export const deltaSchema = z
|
||||
.object({
|
||||
delta: z.number().openapi({
|
||||
description: "The absolute change in value.",
|
||||
example: 10,
|
||||
}),
|
||||
percentage: z.number().openapi({
|
||||
description: "The percentage change in value.",
|
||||
example: 25.0,
|
||||
}),
|
||||
sign: z.enum(["+", "-"]).openapi({
|
||||
description: "The sign of the change.",
|
||||
example: "+",
|
||||
}),
|
||||
variant: z.enum(["increase", "decrease", "positive"]).openapi({
|
||||
description: "The variant of the change.",
|
||||
example: "increase",
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
description: "Delta information representing change over time.",
|
||||
});
|
||||
|
||||
export const publicationMetaSchema = z
|
||||
.object({
|
||||
current: z.number().openapi({
|
||||
description: "The current total value.",
|
||||
example: 150,
|
||||
}),
|
||||
delta: deltaSchema,
|
||||
previous: z.number().openapi({
|
||||
description: "The previous total value.",
|
||||
example: 120,
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
description: "Metadata for publication metrics.",
|
||||
});
|
||||
|
||||
export const publicationsSchema = z
|
||||
.object({
|
||||
items: z.array(publicationSchema).openapi({
|
||||
description: "List of publication metrics for the source.",
|
||||
}),
|
||||
meta: publicationMetaSchema.optional(),
|
||||
})
|
||||
.openapi({
|
||||
description: "Publication metrics for the source.",
|
||||
});
|
||||
|
||||
export const paginationCursorSchema = z
|
||||
.object({
|
||||
date: z.string().openapi({
|
||||
description: "The date associated with the last item in the current page.",
|
||||
example: "2023-01-15",
|
||||
}),
|
||||
id: z.string().openapi({
|
||||
description: "The unique identifier of the last item in the current page.",
|
||||
example: "b3e1c8f4-5d6a-4c9e-8f1e-2d3c4b5a6f7g",
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
description: "Cursor information for pagination.",
|
||||
});
|
||||
|
||||
export const paginationRequestSchema = z
|
||||
.object({
|
||||
cursor: z.string().nullable().optional().openapi({
|
||||
description: "The pagination cursor for cursor-based pagination.",
|
||||
example:
|
||||
"eyJkYXRlIjoiMjAyMy0wMS0xNSIsImlkIjoiYjNlMWM4ZjQtNWQ2YS00YzllLThmMWUtMmQzYzRiNWE2ZjdifQ==",
|
||||
}),
|
||||
limit: limitSchema.optional(),
|
||||
page: z.number().int().min(1).optional().openapi({
|
||||
description: "The page number to retrieve.",
|
||||
example: 1,
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
description: "Pagination request parameters.",
|
||||
});
|
||||
|
||||
export const paginationStateSchema = z
|
||||
.object({
|
||||
cursor: z.string().nullable().openapi({
|
||||
description: "The current pagination cursor.",
|
||||
example:
|
||||
"eyJkYXRlIjoiMjAyMy0wMS0xNSIsImlkIjoiYjNlMWM4ZjQtNWQ2YS00YzllLThmMWUtMmQzYzRiNWE2ZjdifQ==",
|
||||
}),
|
||||
limit: z.number().int().openapi({
|
||||
description: "The number of items per page.",
|
||||
example: 10,
|
||||
}),
|
||||
offset: z.number().int().openapi({
|
||||
description: "The offset for the current page.",
|
||||
example: 0,
|
||||
}),
|
||||
page: z.number().int().openapi({
|
||||
description: "The current page number.",
|
||||
example: 1,
|
||||
}),
|
||||
payload: paginationCursorSchema.nullable().openapi({
|
||||
description: "The decoded payload from the pagination cursor.",
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
description: "Internal pagination state.",
|
||||
});
|
||||
|
||||
export const paginationMetaSchema = z
|
||||
.object({
|
||||
current: z.number().int().openapi({
|
||||
description: "The current page number or offset.",
|
||||
example: 1,
|
||||
}),
|
||||
cursor: z.string().nullable().openapi({
|
||||
description: "The current pagination cursor.",
|
||||
example:
|
||||
"eyJkYXRlIjoiMjAyMy0wMS0xNSIsImlkIjoiYjNlMWM4ZjQtNWQ2YS00YzllLThmMWUtMmQzYzRiNWE2ZjdifQ==",
|
||||
}),
|
||||
hasNext: z.boolean().openapi({
|
||||
description: "Indicates if there is a next page available.",
|
||||
example: true,
|
||||
}),
|
||||
limit: z.number().int().openapi({
|
||||
description: "The number of items per page.",
|
||||
example: 10,
|
||||
}),
|
||||
nextCursor: z.string().nullable().openapi({
|
||||
description: "The next pagination cursor, if available.",
|
||||
example:
|
||||
"eyJkYXRlIjoiMjAyMy0wMS0yMCIsImlkIjoiZDRmNWU2ZTAtNzY4Ny00Y2E3LTg5ZTItYjY0ZGI3Y2E3ZGIifQ==",
|
||||
}),
|
||||
})
|
||||
.openapi({
|
||||
description: "Pagination metadata.",
|
||||
});
|
||||
|
||||
// types
|
||||
export type PaginatedResult<T> = {
|
||||
items: T[];
|
||||
meta: PaginationMeta;
|
||||
};
|
||||
|
||||
export type ID = z.infer<typeof idSchema>;
|
||||
export type DateRange = z.infer<typeof dateRangeSchema>;
|
||||
export type Sentiment = z.infer<typeof sentimentSchema>;
|
||||
export type Bias = z.infer<typeof biasSchema>;
|
||||
export type Reliability = z.infer<typeof reliabilitySchema>;
|
||||
export type Transparency = z.infer<typeof transparencySchema>;
|
||||
export type Credibility = z.infer<typeof credibilitySchema>;
|
||||
export type Device = z.infer<typeof deviceSchema>;
|
||||
export type GeoLocation = z.infer<typeof geoLocationSchema>;
|
||||
|
||||
export type Distribution = z.infer<typeof distrubtionSchema>;
|
||||
export type Distributions = z.infer<typeof distributionsSchema>;
|
||||
export type Publication = z.infer<typeof publicationSchema>;
|
||||
export type Publications = z.infer<typeof publicationsSchema>;
|
||||
export type PublicationMeta = z.infer<typeof publicationMetaSchema>;
|
||||
export type Delta = z.infer<typeof deltaSchema>;
|
||||
|
||||
export type PaginationCursor = z.infer<typeof paginationCursorSchema>;
|
||||
export type PaginationRequest = z.infer<typeof paginationRequestSchema>;
|
||||
export type PaginationState = z.infer<typeof paginationStateSchema>;
|
||||
export type PaginationMeta = z.infer<typeof paginationMetaSchema>;
|
||||
@@ -0,0 +1,63 @@
|
||||
import { z } from "@hono/zod-openapi";
|
||||
|
||||
import {
|
||||
credibilitySchema,
|
||||
idSchema,
|
||||
limitSchema,
|
||||
publicationsSchema,
|
||||
} from "#domain/models/shared";
|
||||
|
||||
// schemas
|
||||
export const sourceSchema = z.object({
|
||||
articles: z.number().int().min(0).optional().openapi({
|
||||
description: "The total number of articles from this source.",
|
||||
example: 1250,
|
||||
}),
|
||||
credibility: credibilitySchema.optional(),
|
||||
description: z.string().max(1024).optional().openapi({
|
||||
description: "A brief description of the source.",
|
||||
example: "Radio Okapi is a Congolese radio station that provides news and information.",
|
||||
}),
|
||||
displayName: z.string().min(1).max(255).optional().openapi({
|
||||
description: "The display name of the source.",
|
||||
example: "Radio Okapi",
|
||||
}),
|
||||
id: idSchema,
|
||||
name: z.string().min(1).max(255).openapi({
|
||||
description: "The name of the source.",
|
||||
example: "radiookapi.com",
|
||||
}),
|
||||
publications: publicationsSchema.optional(),
|
||||
url: z.url().max(255).openapi({
|
||||
description: "The URL of the source.",
|
||||
example: "https://techcrunch.com",
|
||||
}),
|
||||
});
|
||||
|
||||
export const createSourceSchema = sourceSchema.pick({
|
||||
description: true,
|
||||
displayName: true,
|
||||
name: true,
|
||||
url: true,
|
||||
});
|
||||
|
||||
export const getSourceSchema = z.object({
|
||||
id: idSchema,
|
||||
});
|
||||
|
||||
export const getCategorySharesSchema = z.object({
|
||||
id: idSchema,
|
||||
limit: limitSchema.optional(),
|
||||
});
|
||||
|
||||
export const updateSourceSchema = sourceSchema.pick({
|
||||
credibility: true,
|
||||
description: true,
|
||||
displayName: true,
|
||||
id: true,
|
||||
name: true,
|
||||
url: true,
|
||||
});
|
||||
|
||||
// types
|
||||
export type Source = z.infer<typeof sourceSchema>;
|
||||
@@ -0,0 +1,2 @@
|
||||
export type Role = "ROLE_USER" | "ROLE_ADMIN";
|
||||
export type Roles = Role[];
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"exclude": ["node_modules"],
|
||||
"extends": "@basango/tsconfig/base.json",
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -11,6 +11,13 @@
|
||||
"moduleDetection": "force",
|
||||
"moduleResolution": "Bundler",
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"paths": {
|
||||
"#api/*": ["../../apps/api/src/*"],
|
||||
"#crawler/*": ["../../apps/crawler/src/*"],
|
||||
"#dashboard/*": ["../../apps/dashboard/src/*"],
|
||||
"#db/*": ["../db/src/*"],
|
||||
"#domain/*": ["../domain/src/*"]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
@@ -20,9 +21,11 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.1",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"recharts": "^3.4.1",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { Button, buttonVariants } from "@basango/ui/components/button";
|
||||
import { cn } from "@basango/ui/lib/utils";
|
||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
captionLayout={captionLayout}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className,
|
||||
)}
|
||||
classNames={{
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_next,
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
|
||||
defaultClassNames.button_previous,
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label,
|
||||
),
|
||||
day: cn(
|
||||
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
|
||||
props.showWeekNumber
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
|
||||
defaultClassNames.day,
|
||||
),
|
||||
disabled: cn("text-muted-foreground opacity-50", defaultClassNames.disabled),
|
||||
dropdown: cn("absolute bg-popover inset-0 opacity-0", defaultClassNames.dropdown),
|
||||
dropdown_root: cn(
|
||||
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
|
||||
defaultClassNames.dropdown_root,
|
||||
),
|
||||
dropdowns: cn(
|
||||
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
|
||||
defaultClassNames.dropdowns,
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
|
||||
month_caption: cn(
|
||||
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
|
||||
defaultClassNames.month_caption,
|
||||
),
|
||||
months: cn("flex gap-4 flex-col md:flex-row relative", defaultClassNames.months),
|
||||
nav: cn(
|
||||
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
|
||||
defaultClassNames.nav,
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside,
|
||||
),
|
||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_start: cn("rounded-l-md bg-accent", defaultClassNames.range_start),
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
table: "w-full border-collapse",
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today,
|
||||
),
|
||||
week: cn("flex w-full mt-2", defaultClassNames.week),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] select-none text-muted-foreground",
|
||||
defaultClassNames.week_number,
|
||||
),
|
||||
week_number_header: cn("select-none w-(--cell-size)", defaultClassNames.week_number_header),
|
||||
weekday: cn(
|
||||
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
|
||||
defaultClassNames.weekday,
|
||||
),
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return <ChevronLeftIcon className={cn("size-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return <ChevronRightIcon className={cn("size-4", className)} {...props} />;
|
||||
}
|
||||
|
||||
return <ChevronDownIcon className={cn("size-4", className)} {...props} />;
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return <div className={cn(className)} data-slot="calendar" ref={rootRef} {...props} />;
|
||||
},
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
showOutsideDays={showOutsideDays}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus();
|
||||
}, [modifiers.focused]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className,
|
||||
)}
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
ref={ref}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton };
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@basango/ui/lib/utils";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import * as React from "react";
|
||||
|
||||
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
align={align}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
data-slot="popover-content"
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Button } from "@basango/ui/components/button";
|
||||
import { Spinner } from "@basango/ui/components/spinner";
|
||||
import { cn } from "@basango/ui/lib/utils";
|
||||
import * as React from "react";
|
||||
|
||||
export function SubmitButton({
|
||||
children,
|
||||
isSubmitting,
|
||||
disabled,
|
||||
...props
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isSubmitting: boolean;
|
||||
disabled?: boolean;
|
||||
} & React.ComponentProps<"button">) {
|
||||
return (
|
||||
<Button
|
||||
disabled={isSubmitting || disabled}
|
||||
{...props}
|
||||
className={cn("relative", props.className)}
|
||||
>
|
||||
<span className={cn(isSubmitting && "invisible")}>{children}</span>
|
||||
{isSubmitting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user