Compare commits
4 Commits
68893d503d
...
e967244158
| Author | SHA1 | Date | |
|---|---|---|---|
| e967244158 | |||
| 28b01769e5 | |||
| 363c2fc678 | |||
| a47eadd68f |
3
.gitignore
vendored
3
.gitignore
vendored
@ -37,3 +37,6 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Private env files
|
||||||
|
.env
|
||||||
|
|||||||
10040
package-lock.json
generated
10040
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -15,7 +15,9 @@
|
|||||||
"@types/ramda": "^0.29.9",
|
"@types/ramda": "^0.29.9",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
"heat-calendar": "^1.0.7",
|
"heat-calendar": "^1.0.7",
|
||||||
|
"kysely": "^0.27.2",
|
||||||
"next": "^14.1.0",
|
"next": "^14.1.0",
|
||||||
|
"pg": "^8.11.3",
|
||||||
"ramda": "^0.29.1",
|
"ramda": "^0.29.1",
|
||||||
"react": "^18",
|
"react": "^18",
|
||||||
"react-calendar-heatmap": "^1.9.0",
|
"react-calendar-heatmap": "^1.9.0",
|
||||||
@ -23,12 +25,13 @@
|
|||||||
"react-dom": "^18",
|
"react-dom": "^18",
|
||||||
"react-tooltip": "^5.25.0",
|
"react-tooltip": "^5.25.0",
|
||||||
"reaviz": "^15.2.1",
|
"reaviz": "^15.2.1",
|
||||||
"swr": "^2.2.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@testing-library/jest-dom": "^6.1.5",
|
"@testing-library/jest-dom": "^6.1.5",
|
||||||
"@testing-library/react": "^14.1.2",
|
"@testing-library/react": "^14.1.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.11.0",
|
||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-calendar-heatmap": "^1.6.6",
|
"@types/react-calendar-heatmap": "^1.6.6",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
@ -37,7 +40,8 @@
|
|||||||
"eslint-config-next": "14.0.4",
|
"eslint-config-next": "14.0.4",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"postcss": "^8",
|
"kysely-codegen": "^0.11.0",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^3.3.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
|
|||||||
6676
pnpm-lock.yaml
generated
Normal file
6676
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,45 +0,0 @@
|
|||||||
"use server";
|
|
||||||
|
|
||||||
import {OverviewConfig} from "@/app/overviewConfig";
|
|
||||||
|
|
||||||
export interface Data {
|
|
||||||
projects: {
|
|
||||||
projectId: number,
|
|
||||||
name: string,
|
|
||||||
color: string,
|
|
||||||
}[],
|
|
||||||
|
|
||||||
timeEntries: {
|
|
||||||
projectId: number,
|
|
||||||
start: string,
|
|
||||||
end: string,
|
|
||||||
duration: number,
|
|
||||||
// description: string,
|
|
||||||
}[],
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getData(config: OverviewConfig) {
|
|
||||||
const projectIds = config.subjects.map((subject) => subject.projectId);
|
|
||||||
const projectResponse = await fetch(`https://revision.joshuacoles.me/api/project?select=raw_json&toggl_id=in.(${projectIds.join(',')})`);
|
|
||||||
const projects = await projectResponse.json();
|
|
||||||
const projectLensed = projects.map((project: any) => ({
|
|
||||||
projectId: project.raw_json.id,
|
|
||||||
name: project.raw_json.name,
|
|
||||||
color: project.raw_json.color,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const timeEntriesResponse = await fetch(`https://revision.joshuacoles.me/api/time_entry?select=project_id,raw_json&project_id=in.(${projectIds.join(',')})&start=gt.${config.timePeriod.start}&start=lt.${config.timePeriod.end}`);
|
|
||||||
const timeEntries = await timeEntriesResponse.json();
|
|
||||||
const timeEntriesLensed = timeEntries.map((timeEntry: any) => ({
|
|
||||||
projectId: timeEntry.project_id,
|
|
||||||
start: timeEntry.raw_json.start,
|
|
||||||
end: timeEntry.raw_json.end,
|
|
||||||
duration: timeEntry.raw_json.seconds,
|
|
||||||
// description: timeEntry.raw_json.description,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
projects: projectLensed,
|
|
||||||
timeEntries: timeEntriesLensed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,6 +1,18 @@
|
|||||||
import OverviewPage from "@/app/OverviewPage";
|
import Link from "next/link";
|
||||||
import {semester1Revision} from "@/app/overviewConfig";
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return <OverviewPage config={semester1Revision}/>
|
return <main className="m-6 text-slate-900 dark:text-white">
|
||||||
|
<h1 className="text-3xl font-semibold my-2">Work Tracker</h1>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li>
|
||||||
|
<Link href="/sem1-revision" className={"text-blue-500 underline underline-offset-2"}>
|
||||||
|
Semester 1 Revision
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/sem2" className={"text-blue-500 underline underline-offset-2"}>Semester 2</Link>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</main>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +1,6 @@
|
|||||||
export interface OverviewConfig {
|
import OverviewPage, {OverviewConfig} from "@/components/OverviewPage";
|
||||||
title: string,
|
|
||||||
|
|
||||||
subjects: {
|
const semester1Revision: OverviewConfig = {
|
||||||
title?: string,
|
|
||||||
projectId: number,
|
|
||||||
}[],
|
|
||||||
|
|
||||||
goalHours: number,
|
|
||||||
|
|
||||||
timePeriod: {
|
|
||||||
start: string,
|
|
||||||
end: string
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export const semester1Revision: OverviewConfig = {
|
|
||||||
title: 'Semester 1 Revision',
|
title: 'Semester 1 Revision',
|
||||||
goalHours: 4,
|
goalHours: 4,
|
||||||
subjects: [
|
subjects: [
|
||||||
@ -38,3 +24,7 @@ export const semester1Revision: OverviewConfig = {
|
|||||||
end: "2024-01-25T00:00:00.000Z"
|
end: "2024-01-25T00:00:00.000Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return <OverviewPage config={semester1Revision}/>
|
||||||
|
}
|
||||||
@ -1,9 +1,8 @@
|
|||||||
import OverviewPage from "@/app/OverviewPage";
|
import OverviewPage, {OverviewConfig} from "@/components/OverviewPage";
|
||||||
import {OverviewConfig} from "@/app/overviewConfig";
|
|
||||||
|
|
||||||
const semester2: OverviewConfig = {
|
const semester2: OverviewConfig = {
|
||||||
title: 'Semester 2',
|
title: 'Semester 2',
|
||||||
goalHours: 4,
|
goalHours: 7.5,
|
||||||
subjects: [
|
subjects: [
|
||||||
{
|
{
|
||||||
projectId: 195754611,
|
projectId: 195754611,
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
export const fetcher = (
|
|
||||||
input: string | URL | globalThis.Request,
|
|
||||||
init?: RequestInit,
|
|
||||||
) => fetch(input, init).then(res => res.json())
|
|
||||||
@ -1,13 +1,28 @@
|
|||||||
import {SubjectComparisonCard} from "@/app/cards/subjectComparisonCard";
|
import {SubjectComparisonCard} from "@/components/cards/subjectComparisonCard";
|
||||||
import {CalendarOverviewCard} from "@/app/cards/calendarOverviewCard";
|
import {CalendarOverviewCard} from "@/components/cards/calendarOverviewCard";
|
||||||
import {OverviewConfig} from "@/app/overviewConfig";
|
import {SubjectOverviewCard} from "@/components/cards/subjectOverviewCard";
|
||||||
import {SubjectOverviewCard} from "@/app/cards/subjectOverviewCard";
|
import {getDataSQL} from "@/data/fetchWithSQL";
|
||||||
import {getData} from "@/app/fetchData";
|
|
||||||
|
export interface OverviewConfig {
|
||||||
|
title: string,
|
||||||
|
|
||||||
|
subjects: {
|
||||||
|
title?: string,
|
||||||
|
projectId: number,
|
||||||
|
}[],
|
||||||
|
|
||||||
|
goalHours: number,
|
||||||
|
|
||||||
|
timePeriod: {
|
||||||
|
start: string,
|
||||||
|
end: string
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
export default async function OverviewPage({config}: {
|
export default async function OverviewPage({config}: {
|
||||||
config: OverviewConfig
|
config: OverviewConfig
|
||||||
}) {
|
}) {
|
||||||
const data = await getData(config);
|
const data = await getDataSQL(config);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="m-6">
|
<main className="m-6">
|
||||||
@ -25,6 +40,7 @@ export default async function OverviewPage({config}: {
|
|||||||
|
|
||||||
<CalendarOverviewCard
|
<CalendarOverviewCard
|
||||||
data={data}
|
data={data}
|
||||||
|
goal={config.goalHours}
|
||||||
startTime={config.timePeriod.start}
|
startTime={config.timePeriod.start}
|
||||||
endTime={config.timePeriod.end}
|
endTime={config.timePeriod.end}
|
||||||
/>
|
/>
|
||||||
@ -4,16 +4,19 @@ import * as R from 'ramda';
|
|||||||
import * as dFns from 'date-fns';
|
import * as dFns from 'date-fns';
|
||||||
import CalendarHeatmap from 'react-calendar-heatmap';
|
import CalendarHeatmap from 'react-calendar-heatmap';
|
||||||
import 'react-calendar-heatmap/dist/styles.css';
|
import 'react-calendar-heatmap/dist/styles.css';
|
||||||
import '../calendar-styles.css'
|
import '../../app/calendar-styles.css'
|
||||||
import {Tooltip} from 'react-tooltip';
|
import {Tooltip} from 'react-tooltip';
|
||||||
import {Data} from "@/app/fetchData";
|
import {Data} from "@/data/fetchData";
|
||||||
|
|
||||||
const dailyGoal = 4;
|
|
||||||
const granularity = 4;
|
const granularity = 4;
|
||||||
|
|
||||||
function computeCompletionShade(value: number) {
|
function computeCompletionShade(value: number, dailyGoal: number) {
|
||||||
const linearValue = Math.round((value / dailyGoal) * granularity);
|
const linearValue = Math.round((value / dailyGoal) * granularity);
|
||||||
|
|
||||||
|
// If we did something, but not enough to reach the first level, return 1
|
||||||
if (linearValue == 0 && value > 0) return 1;
|
if (linearValue == 0 && value > 0) return 1;
|
||||||
|
|
||||||
|
// Clamp to the granularity
|
||||||
if (linearValue > granularity) return granularity;
|
if (linearValue > granularity) return granularity;
|
||||||
return linearValue;
|
return linearValue;
|
||||||
}
|
}
|
||||||
@ -50,10 +53,12 @@ function useCalendarData(data: Data, initialDate: Date, endDate: Date) {
|
|||||||
|
|
||||||
export function CalendarOverviewCard({
|
export function CalendarOverviewCard({
|
||||||
data,
|
data,
|
||||||
|
goal,
|
||||||
startTime,
|
startTime,
|
||||||
endTime
|
endTime,
|
||||||
}: {
|
}: {
|
||||||
data: Data,
|
data: Data,
|
||||||
|
goal: number,
|
||||||
startTime: string,
|
startTime: string,
|
||||||
endTime: string,
|
endTime: string,
|
||||||
}) {
|
}) {
|
||||||
@ -69,7 +74,7 @@ export function CalendarOverviewCard({
|
|||||||
startDate={initialDate}
|
startDate={initialDate}
|
||||||
endDate={endDate}
|
endDate={endDate}
|
||||||
values={calendarData}
|
values={calendarData}
|
||||||
classForValue={value => `color-github-${computeCompletionShade(value?.count ?? 0)}`}
|
classForValue={value => `color-github-${computeCompletionShade(value?.count ?? 0, goal)}`}
|
||||||
tooltipDataAttrs={(value: any) => {
|
tooltipDataAttrs={(value: any) => {
|
||||||
return value.date ? {
|
return value.date ? {
|
||||||
'data-tooltip-id': `calendar-tooltip`,
|
'data-tooltip-id': `calendar-tooltip`,
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import {Card, DonutChart, Title} from "@tremor/react";
|
import {Card, DonutChart, Title} from "@tremor/react";
|
||||||
import * as R from "ramda";
|
import * as R from "ramda";
|
||||||
import {Data} from "@/app/fetchData";
|
import {Data} from "@/data/fetchData";
|
||||||
|
|
||||||
function useBreakdownData(data: Data): {
|
function useBreakdownData(data: Data): {
|
||||||
name: string,
|
name: string,
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import {Card, Metric, Text, Title} from "@tremor/react";
|
import {Card, Metric, Text, Title} from "@tremor/react";
|
||||||
import {Data} from "@/app/fetchData";
|
import {Data} from "@/data/fetchData";
|
||||||
|
|
||||||
export function SubjectOverviewCard({
|
export function SubjectOverviewCard({
|
||||||
title,
|
title,
|
||||||
72
src/data/database.ts
Normal file
72
src/data/database.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import {DB as Database} from './db' // this is the Database interface we defined earlier
|
||||||
|
import {Pool} from 'pg'
|
||||||
|
import {Kysely, PostgresDialect} from 'kysely'
|
||||||
|
import * as fs from "node:fs";
|
||||||
|
import {z} from 'zod';
|
||||||
|
|
||||||
|
const envSchema = z.object({
|
||||||
|
POSTGRES_DB: z.string(),
|
||||||
|
POSTGRES_HOST: z.string(),
|
||||||
|
POSTGRES_PORT: z.string().transform((val, ctx) => {
|
||||||
|
const parsed = parseInt(val);
|
||||||
|
if (isNaN(parsed)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Not a number",
|
||||||
|
});
|
||||||
|
|
||||||
|
return z.NEVER;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}),
|
||||||
|
|
||||||
|
POSTGRES_USER: z.string().optional(),
|
||||||
|
POSTGRES_USER_FILE: z.string().optional(),
|
||||||
|
POSTGRES_PASSWORD: z.string().optional(),
|
||||||
|
POSTGRES_PASSWORD_FILE: z.string().optional(),
|
||||||
|
}).refine(
|
||||||
|
env => env.POSTGRES_USER || env.POSTGRES_USER_FILE,
|
||||||
|
{
|
||||||
|
message: "Either POSTGRES_USER or POSTGRES_USER_FILE must be set",
|
||||||
|
path: ["POSTGRES_USER", "POSTGRES_USER_FILE"],
|
||||||
|
}
|
||||||
|
).refine(
|
||||||
|
env => env.POSTGRES_PASSWORD || env.POSTGRES_PASSWORD_FILE,
|
||||||
|
{
|
||||||
|
message: "Either POSTGRES_PASSWORD or POSTGRES_PASSWORD_FILE must be set",
|
||||||
|
path: ["POSTGRES_PASSWORD", "POSTGRES_PASSWORD_FILE"],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const env = envSchema.parse(process.env);
|
||||||
|
|
||||||
|
function fileOrEnv(fileKey: keyof typeof env, valueKey: keyof typeof env): string | undefined {
|
||||||
|
const file: string = env[fileKey] as string;
|
||||||
|
|
||||||
|
if (file && fs.existsSync(file)) {
|
||||||
|
return fs.readFileSync(file, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
return env[valueKey] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCredentials() {
|
||||||
|
return {
|
||||||
|
user: fileOrEnv('POSTGRES_USER_FILE', 'POSTGRES_USER'),
|
||||||
|
password: fileOrEnv('POSTGRES_PASSWORD_FILE', 'POSTGRES_PASSWORD'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialect = new PostgresDialect({
|
||||||
|
pool: new Pool({
|
||||||
|
database: env.POSTGRES_DB,
|
||||||
|
host: env.POSTGRES_HOST,
|
||||||
|
port: env.POSTGRES_PORT,
|
||||||
|
...getCredentials(),
|
||||||
|
max: 10,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const db = new Kysely<Database>({
|
||||||
|
dialect,
|
||||||
|
})
|
||||||
70
src/data/db.d.ts
vendored
Normal file
70
src/data/db.d.ts
vendored
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import type {ColumnType, JSONColumnType} from "kysely";
|
||||||
|
|
||||||
|
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||||
|
? ColumnType<S, I | undefined, U>
|
||||||
|
: ColumnType<T, T | undefined, T>;
|
||||||
|
|
||||||
|
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
|
||||||
|
|
||||||
|
export type Json = ColumnType<JsonValue, string, string>;
|
||||||
|
|
||||||
|
export type JsonArray = JsonValue[];
|
||||||
|
|
||||||
|
export type JsonObject = {
|
||||||
|
[K in string]?: JsonValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JsonPrimitive = boolean | number | string | null;
|
||||||
|
|
||||||
|
export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
|
||||||
|
|
||||||
|
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
|
||||||
|
|
||||||
|
export interface Client {
|
||||||
|
archived: boolean;
|
||||||
|
at: Timestamp;
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
server_deleted_at: Timestamp | null;
|
||||||
|
workspace_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Project {
|
||||||
|
active: boolean;
|
||||||
|
client_id: number | null;
|
||||||
|
id: Generated<number>;
|
||||||
|
name: string;
|
||||||
|
raw_json: JSONColumnType<{
|
||||||
|
color: string;
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
toggl_id: Int8;
|
||||||
|
workspace_id: Int8;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeEntry {
|
||||||
|
description: string;
|
||||||
|
id: Generated<number>;
|
||||||
|
project_id: Int8 | null;
|
||||||
|
raw_json: JSONColumnType<{
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
seconds: number;
|
||||||
|
}>;
|
||||||
|
start: Timestamp;
|
||||||
|
stop: Timestamp;
|
||||||
|
toggl_id: Int8;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TogglPortalSeaqlMigrations {
|
||||||
|
applied_at: Int8;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DB {
|
||||||
|
client: Client;
|
||||||
|
project: Project;
|
||||||
|
time_entry: TimeEntry;
|
||||||
|
toggl_portal_seaql_migrations: TogglPortalSeaqlMigrations;
|
||||||
|
}
|
||||||
17
src/data/fetchData.ts
Normal file
17
src/data/fetchData.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
export interface Data {
|
||||||
|
projects: {
|
||||||
|
projectId: number,
|
||||||
|
name: string,
|
||||||
|
color: string,
|
||||||
|
}[],
|
||||||
|
|
||||||
|
timeEntries: {
|
||||||
|
projectId: number,
|
||||||
|
start: string,
|
||||||
|
end: string,
|
||||||
|
duration: number,
|
||||||
|
// description: string,
|
||||||
|
}[],
|
||||||
|
}
|
||||||
35
src/data/fetchWithSQL.ts
Normal file
35
src/data/fetchWithSQL.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import {OverviewConfig} from "@/components/OverviewPage";
|
||||||
|
import {Data} from "@/data/fetchData";
|
||||||
|
import {db} from "@/data/database";
|
||||||
|
import * as dFns from "date-fns";
|
||||||
|
|
||||||
|
export async function getDataSQL(config: OverviewConfig): Promise<Data> {
|
||||||
|
let projectIds = config.subjects.map((subject) => subject.projectId.toString());
|
||||||
|
|
||||||
|
const projects = await db.selectFrom('project')
|
||||||
|
.select('raw_json')
|
||||||
|
.where('project.toggl_id', 'in', projectIds)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
const timeEntries = await db.selectFrom('time_entry')
|
||||||
|
.select(['project_id', 'raw_json'])
|
||||||
|
.where('project_id', 'in', projectIds)
|
||||||
|
.where('start', '>', dFns.parseISO(config.timePeriod.start))
|
||||||
|
.where('start', '<', dFns.parseISO(config.timePeriod.end))
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects: projects.map((project) => ({
|
||||||
|
projectId: project.raw_json.id,
|
||||||
|
name: project.raw_json.name,
|
||||||
|
color: project.raw_json.color,
|
||||||
|
})),
|
||||||
|
|
||||||
|
timeEntries: timeEntries.map((timeEntry) => ({
|
||||||
|
projectId: parseInt(timeEntry.project_id!),
|
||||||
|
start: timeEntry.raw_json.start,
|
||||||
|
end: timeEntry.raw_json.end,
|
||||||
|
duration: timeEntry.raw_json.seconds,
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user