@axium/contacts 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +157 -0
- package/README.md +3 -0
- package/db.json +131 -0
- package/dist/client/api.d.ts +2 -0
- package/dist/client/api.js +4 -0
- package/dist/client/format.d.ts +28 -0
- package/dist/client/format.js +71 -0
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.js +2 -0
- package/dist/client/web_hook.d.ts +8 -0
- package/dist/client/web_hook.js +4 -0
- package/dist/common.d.ts +698 -0
- package/dist/common.js +114 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/server/api.d.ts +1 -0
- package/dist/server/api.js +138 -0
- package/dist/server/db.d.ts +21 -0
- package/dist/server/db.js +75 -0
- package/dist/server/hooks.d.ts +4 -0
- package/dist/server/hooks.js +8 -0
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/pfp.d.ts +1 -0
- package/dist/server/pfp.js +81 -0
- package/lib/ContactCard.svelte +21 -0
- package/lib/ContactPicture.svelte +46 -0
- package/lib/DateSelect.svelte +25 -0
- package/lib/Discovery.svelte +101 -0
- package/lib/Field.svelte +31 -0
- package/lib/InitForm.svelte +264 -0
- package/lib/List.svelte +37 -0
- package/lib/index.ts +7 -0
- package/lib/tsconfig.json +13 -0
- package/locales/en.json +72 -0
- package/package.json +69 -0
- package/routes/contacts/+layout.ts +10 -0
- package/routes/contacts/+page.svelte +29 -0
- package/routes/contacts/+page.ts +15 -0
- package/routes/contacts/[id]/+layout.ts +12 -0
- package/routes/contacts/[id]/+page.svelte +247 -0
- package/routes/contacts/[id]/edit/+page.svelte +25 -0
- package/routes/contacts/new/+page.svelte +33 -0
- package/routes/contacts/new/+page.ts +11 -0
- package/routes/tsconfig.json +13 -0
package/dist/common.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { Location, serverConfigs } from '@axium/core';
|
|
2
|
+
import { $API } from '@axium/core/api';
|
|
3
|
+
import { zKeys } from '@axium/core/locales';
|
|
4
|
+
import * as z from 'zod';
|
|
5
|
+
const SmallText = z.string().nonempty().max(100);
|
|
6
|
+
const LabelRequired = SmallText.clone().register(zKeys, { key: 'contact.label' });
|
|
7
|
+
const Label = SmallText.nullish().register(zKeys, { key: 'contact.label' });
|
|
8
|
+
const IsDefault = z.boolean().default(false).register(zKeys, { key: 'contact.default' });
|
|
9
|
+
const Day = z.coerce.number().int().min(1).max(31);
|
|
10
|
+
const Month = z.coerce.number().int().min(1).max(12);
|
|
11
|
+
const Year = z.coerce.number().int().min(1).max(9999).nullish();
|
|
12
|
+
const DatePartNull = z.coerce
|
|
13
|
+
.number()
|
|
14
|
+
.int()
|
|
15
|
+
.min(0)
|
|
16
|
+
.max(0)
|
|
17
|
+
.transform(() => null);
|
|
18
|
+
export const ContactURL = z.url().max(100);
|
|
19
|
+
export const Email = z.object({
|
|
20
|
+
email: z.email().max(255).register(zKeys, { key: 'contact.email' }),
|
|
21
|
+
label: Label,
|
|
22
|
+
isDefault: IsDefault,
|
|
23
|
+
});
|
|
24
|
+
export const Address = Location.extend({
|
|
25
|
+
label: Label,
|
|
26
|
+
isDefault: IsDefault,
|
|
27
|
+
});
|
|
28
|
+
export const Phone = z.object({
|
|
29
|
+
country: z.int().min(0).max(999).nullish(),
|
|
30
|
+
number: z.coerce
|
|
31
|
+
.bigint()
|
|
32
|
+
// @todo check length based on country code
|
|
33
|
+
.refine(val => val > 0n && val.toString().length)
|
|
34
|
+
.register(zKeys, { key: 'contact.phone.number' }),
|
|
35
|
+
label: Label,
|
|
36
|
+
isDefault: IsDefault,
|
|
37
|
+
});
|
|
38
|
+
export const SigDate = z.object({
|
|
39
|
+
year: Year,
|
|
40
|
+
month: Month,
|
|
41
|
+
day: Day,
|
|
42
|
+
label: Label,
|
|
43
|
+
});
|
|
44
|
+
export const Relationship = z.object({
|
|
45
|
+
to: z.uuid().register(zKeys, { key: 'contact.relationship.to' }),
|
|
46
|
+
label: LabelRequired,
|
|
47
|
+
});
|
|
48
|
+
export const Custom = z.object({
|
|
49
|
+
label: Label,
|
|
50
|
+
value: z.string().max(255).register(zKeys, { key: 'contact.custom.value' }),
|
|
51
|
+
});
|
|
52
|
+
export const Init = z.object({
|
|
53
|
+
display: SmallText.nullish().register(zKeys, { key: 'contact.display' }),
|
|
54
|
+
prefix: SmallText.nullish().register(zKeys, { key: 'contact.prefix' }),
|
|
55
|
+
givenName: SmallText.nullish().register(zKeys, { key: 'contact.given_name' }),
|
|
56
|
+
givenName2: SmallText.nullish().register(zKeys, { key: 'contact.given_name_2' }),
|
|
57
|
+
surname: SmallText.nullish().register(zKeys, { key: 'contact.surname' }),
|
|
58
|
+
suffix: SmallText.nullish().register(zKeys, { key: 'contact.suffix' }),
|
|
59
|
+
nickname: SmallText.nullish().register(zKeys, { key: 'contact.nickname' }),
|
|
60
|
+
company: SmallText.nullish().register(zKeys, { key: 'contact.company' }),
|
|
61
|
+
jobTitle: SmallText.nullish().register(zKeys, { key: 'contact.job_title' }),
|
|
62
|
+
department: SmallText.nullish().register(zKeys, { key: 'contact.department' }),
|
|
63
|
+
notes: z.string().max(1000).nullish().register(zKeys, { key: 'contact.notes' }),
|
|
64
|
+
birthDay: Day.or(DatePartNull).nullish(),
|
|
65
|
+
birthMonth: Month.or(DatePartNull).nullish(),
|
|
66
|
+
birthYear: Year.or(DatePartNull),
|
|
67
|
+
urls: ContactURL.array().max(100).default([]),
|
|
68
|
+
emails: Email.array().max(100).default([]),
|
|
69
|
+
addresses: Address.array().max(100).default([]),
|
|
70
|
+
phones: Phone.array().max(100).default([]),
|
|
71
|
+
dates: SigDate.array().max(100).default([]),
|
|
72
|
+
relationships: Relationship.array().max(100).default([]),
|
|
73
|
+
custom: Custom.array().max(100).default([]),
|
|
74
|
+
linkedUserId: z.uuid().nullish(),
|
|
75
|
+
});
|
|
76
|
+
export const Contact = Init.extend({
|
|
77
|
+
id: z.uuid(),
|
|
78
|
+
userId: z.uuid(),
|
|
79
|
+
createdAt: z.coerce.date(),
|
|
80
|
+
updatedAt: z.coerce.date(),
|
|
81
|
+
});
|
|
82
|
+
const _externalFields = {
|
|
83
|
+
addresses: true,
|
|
84
|
+
emails: true,
|
|
85
|
+
phones: true,
|
|
86
|
+
dates: true,
|
|
87
|
+
relationships: true,
|
|
88
|
+
custom: true,
|
|
89
|
+
};
|
|
90
|
+
/**
|
|
91
|
+
* Large contact fields, which are stored externally
|
|
92
|
+
*/
|
|
93
|
+
export const OnlyExternal = Contact.pick(_externalFields);
|
|
94
|
+
export const InitNoExternal = Init.omit(_externalFields);
|
|
95
|
+
export const NoExternal = Contact.omit(_externalFields);
|
|
96
|
+
const ContactsConfig = z.object({
|
|
97
|
+
auto_link: z.boolean(),
|
|
98
|
+
});
|
|
99
|
+
serverConfigs.set('@axium/contacts', ContactsConfig);
|
|
100
|
+
const ContactsAPI = {
|
|
101
|
+
'users/:id/contacts': {
|
|
102
|
+
GET: Contact.array(),
|
|
103
|
+
PUT: [Init, Contact],
|
|
104
|
+
},
|
|
105
|
+
'contact-discovery': {
|
|
106
|
+
POST: [z.string(), NoExternal.array()],
|
|
107
|
+
},
|
|
108
|
+
'contacts/:id': {
|
|
109
|
+
GET: Contact,
|
|
110
|
+
PATCH: [Init, Contact],
|
|
111
|
+
DELETE: Contact,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
Object.assign($API, ContactsAPI);
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './common.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './common.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { checkAuthForUser, requireSession } from '@axium/server/auth';
|
|
2
|
+
import { database } from '@axium/server/database';
|
|
3
|
+
import { parseBody, withError } from '@axium/server/requests';
|
|
4
|
+
import { addRoute } from '@axium/server/routes';
|
|
5
|
+
import * as z from 'zod';
|
|
6
|
+
import * as contact from '../common.js';
|
|
7
|
+
import { contactsFields, insertContactFields, tryAutoLink } from './db.js';
|
|
8
|
+
function splitInit(init) {
|
|
9
|
+
const { addresses, emails, phones, dates, relationships, custom, ...rest } = init;
|
|
10
|
+
return [rest, { addresses, emails, phones, dates, relationships, custom }];
|
|
11
|
+
}
|
|
12
|
+
addRoute({
|
|
13
|
+
path: '/api/users/:id/contacts',
|
|
14
|
+
params: { id: z.uuid() },
|
|
15
|
+
async GET(request, { id: userId }) {
|
|
16
|
+
await checkAuthForUser(request, userId);
|
|
17
|
+
return await database.selectFrom('contacts').selectAll().select(contactsFields).where('userId', '=', userId).execute();
|
|
18
|
+
},
|
|
19
|
+
async PUT(request, { id: userId }) {
|
|
20
|
+
const init = await parseBody(request, contact.Init);
|
|
21
|
+
await checkAuthForUser(request, userId);
|
|
22
|
+
await tryAutoLink(init);
|
|
23
|
+
const [contactInit, fieldsInit] = splitInit(init);
|
|
24
|
+
const tx = await database.startTransaction().execute();
|
|
25
|
+
try {
|
|
26
|
+
const contact = await tx
|
|
27
|
+
.insertInto('contacts')
|
|
28
|
+
.values({ ...contactInit, userId })
|
|
29
|
+
.returningAll()
|
|
30
|
+
.executeTakeFirstOrThrow();
|
|
31
|
+
const rest = await insertContactFields(tx, contact.id, fieldsInit);
|
|
32
|
+
await tx.commit().execute();
|
|
33
|
+
return Object.assign(contact, rest);
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
await tx.rollback().execute();
|
|
37
|
+
throw e;
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
addRoute({
|
|
42
|
+
path: '/api/contacts/:id',
|
|
43
|
+
params: { id: z.uuid() },
|
|
44
|
+
async GET(request, { id }) {
|
|
45
|
+
const contact = await database
|
|
46
|
+
.selectFrom('contacts')
|
|
47
|
+
.selectAll()
|
|
48
|
+
.select(contactsFields)
|
|
49
|
+
.where('id', '=', id)
|
|
50
|
+
.executeTakeFirstOrThrow()
|
|
51
|
+
.catch(withError('Contact does not exist', 404));
|
|
52
|
+
await checkAuthForUser(request, contact.userId);
|
|
53
|
+
return contact;
|
|
54
|
+
},
|
|
55
|
+
async PATCH(request, { id }) {
|
|
56
|
+
const init = await parseBody(request, contact.Init);
|
|
57
|
+
const { userId } = await database
|
|
58
|
+
.selectFrom('contacts')
|
|
59
|
+
.select('userId')
|
|
60
|
+
.where('id', '=', id)
|
|
61
|
+
.executeTakeFirstOrThrow()
|
|
62
|
+
.catch(withError('Contact does not exist', 404));
|
|
63
|
+
await checkAuthForUser(request, userId);
|
|
64
|
+
await tryAutoLink(init);
|
|
65
|
+
const [contactInit, fieldsInit] = splitInit(init);
|
|
66
|
+
const tx = await database.startTransaction().execute();
|
|
67
|
+
try {
|
|
68
|
+
const contact = await database
|
|
69
|
+
.updateTable('contacts')
|
|
70
|
+
.set({ ...contactInit, updatedAt: new Date() })
|
|
71
|
+
.where('id', '=', id)
|
|
72
|
+
.returningAll()
|
|
73
|
+
.executeTakeFirstOrThrow();
|
|
74
|
+
await Promise.all([
|
|
75
|
+
tx.deleteFrom('contact_addresses').where('id', '=', id).execute(),
|
|
76
|
+
tx.deleteFrom('contact_emails').where('id', '=', id).execute(),
|
|
77
|
+
tx.deleteFrom('contact_phones').where('id', '=', id).execute(),
|
|
78
|
+
tx.deleteFrom('contact_dates').where('id', '=', id).execute(),
|
|
79
|
+
tx.deleteFrom('contact_relationships').where('id', '=', id).execute(),
|
|
80
|
+
tx.deleteFrom('contact_custom').where('id', '=', id).execute(),
|
|
81
|
+
]);
|
|
82
|
+
const rest = await insertContactFields(tx, contact.id, fieldsInit);
|
|
83
|
+
await tx.commit().execute();
|
|
84
|
+
return Object.assign(contact, rest);
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
await tx.rollback().execute();
|
|
88
|
+
throw e;
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
async DELETE(request, { id }) {
|
|
92
|
+
const { userId } = await database
|
|
93
|
+
.selectFrom('contacts')
|
|
94
|
+
.select('userId')
|
|
95
|
+
.where('id', '=', id)
|
|
96
|
+
.executeTakeFirstOrThrow()
|
|
97
|
+
.catch(withError('Contact does not exist', 404));
|
|
98
|
+
await checkAuthForUser(request, userId);
|
|
99
|
+
return await database
|
|
100
|
+
.deleteFrom('contacts')
|
|
101
|
+
.where('id', '=', id)
|
|
102
|
+
.returningAll()
|
|
103
|
+
.returning(contactsFields)
|
|
104
|
+
.executeTakeFirstOrThrow();
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
addRoute({
|
|
108
|
+
path: '/api/contact-discovery',
|
|
109
|
+
async POST(request) {
|
|
110
|
+
const query = await parseBody(request, z.string().max(100));
|
|
111
|
+
const { userId } = await requireSession(request);
|
|
112
|
+
return await database
|
|
113
|
+
.selectFrom('contacts')
|
|
114
|
+
.selectAll()
|
|
115
|
+
.where('userId', '=', userId)
|
|
116
|
+
.where(eb => eb.or([
|
|
117
|
+
eb(eb.fn('concat_ws', [eb.val(' '), 'givenName', 'givenName2', 'surname']), 'like', `%${query}%`),
|
|
118
|
+
eb('id', 'in', eb.selectFrom('contact_emails').select('id').where('email', 'like', `%${query}%`)),
|
|
119
|
+
eb('id', 'in', eb
|
|
120
|
+
.selectFrom('contact_phones')
|
|
121
|
+
.select('id')
|
|
122
|
+
.where(eb => eb.cast('number', 'text'), 'like', `%${query}%`)),
|
|
123
|
+
eb('id', 'in', eb
|
|
124
|
+
.selectFrom('contact_addresses')
|
|
125
|
+
.select('id')
|
|
126
|
+
.where(eb => eb.fn('concat_ws', [
|
|
127
|
+
eb.val(' '),
|
|
128
|
+
'street1',
|
|
129
|
+
'street2',
|
|
130
|
+
'locality',
|
|
131
|
+
'subdivision',
|
|
132
|
+
'postalCode',
|
|
133
|
+
'country',
|
|
134
|
+
]), 'like', `%${query}%`)),
|
|
135
|
+
]))
|
|
136
|
+
.execute();
|
|
137
|
+
},
|
|
138
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Country } from '@axium/core';
|
|
2
|
+
import { type Schema as DB } from '@axium/server/database';
|
|
3
|
+
import type { FromFile as FromSchemaFile } from '@axium/server/db/schema';
|
|
4
|
+
import type { AliasedRawBuilder, ControlledTransaction, ExpressionBuilder } from 'kysely';
|
|
5
|
+
import type schema from '../../db.json';
|
|
6
|
+
import * as contact from '../common.js';
|
|
7
|
+
declare module '@axium/server/database' {
|
|
8
|
+
interface _DB extends FromSchemaFile<typeof schema> {
|
|
9
|
+
}
|
|
10
|
+
interface Schema extends _DB {
|
|
11
|
+
contact_addresses: _DB['contact_addresses'] & {
|
|
12
|
+
country: Country;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export declare function contactsFields(eb: ExpressionBuilder<DB, 'contacts'>): readonly [AliasedRawBuilder<contact.Address[], "addresses">, AliasedRawBuilder<contact.Email[], "emails">, AliasedRawBuilder<contact.Phone[], "phones">, AliasedRawBuilder<contact.SigDate[], "dates">, AliasedRawBuilder<contact.Relationship[], "relationships">, AliasedRawBuilder<contact.Custom[], "custom">];
|
|
17
|
+
/**
|
|
18
|
+
* Try to automatically link the contact to a user
|
|
19
|
+
*/
|
|
20
|
+
export declare function tryAutoLink(init: contact.Init): Promise<void>;
|
|
21
|
+
export declare function insertContactFields(tx: ControlledTransaction<DB, []>, id: string, init: contact.OnlyExternal): Promise<contact.OnlyExternal>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { getConfig } from '@axium/core';
|
|
2
|
+
import { database } from '@axium/server/database';
|
|
3
|
+
import { error } from '@axium/server/requests';
|
|
4
|
+
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
|
5
|
+
import * as contact from '../common.js';
|
|
6
|
+
export function contactsFields(eb) {
|
|
7
|
+
function select(field) {
|
|
8
|
+
return jsonArrayFrom(eb
|
|
9
|
+
.selectFrom(`contact_${field}`)
|
|
10
|
+
// @ts-expect-error 2349
|
|
11
|
+
.select(Object.keys(fieldSchemas[field].shape))
|
|
12
|
+
.whereRef('id', '=', 'contacts.id'))
|
|
13
|
+
.$castTo()
|
|
14
|
+
.as(field);
|
|
15
|
+
}
|
|
16
|
+
return [select('addresses'), select('emails'), select('phones'), select('dates'), select('relationships'), select('custom')];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Try to automatically link the contact to a user
|
|
20
|
+
*/
|
|
21
|
+
export async function tryAutoLink(init) {
|
|
22
|
+
if (!getConfig('@axium/contacts').auto_link || init.linkedUserId || !init.emails.length)
|
|
23
|
+
return;
|
|
24
|
+
const emails = init.emails.map(e => e.email);
|
|
25
|
+
const user = await database.selectFrom('users').select('id').where('email', 'in', emails).executeTakeFirst();
|
|
26
|
+
if (!user)
|
|
27
|
+
return;
|
|
28
|
+
init.linkedUserId = user.id;
|
|
29
|
+
}
|
|
30
|
+
const fieldSchemas = {
|
|
31
|
+
addresses: contact.Address,
|
|
32
|
+
emails: contact.Email,
|
|
33
|
+
phones: contact.Phone,
|
|
34
|
+
dates: contact.SigDate,
|
|
35
|
+
relationships: contact.Relationship,
|
|
36
|
+
custom: contact.Custom,
|
|
37
|
+
};
|
|
38
|
+
export async function insertContactFields(tx, id, init) {
|
|
39
|
+
for (const [name, data] of [
|
|
40
|
+
['addresses', init.addresses],
|
|
41
|
+
['emails', init.emails],
|
|
42
|
+
['phones', init.phones],
|
|
43
|
+
]) {
|
|
44
|
+
let defaultItem;
|
|
45
|
+
for (const item of data) {
|
|
46
|
+
if (!item.isDefault)
|
|
47
|
+
continue;
|
|
48
|
+
if (defaultItem)
|
|
49
|
+
error(400, 'Can not have multiple default ' + name);
|
|
50
|
+
defaultItem = item;
|
|
51
|
+
}
|
|
52
|
+
if (!defaultItem && data.length)
|
|
53
|
+
data[0].isDefault = true;
|
|
54
|
+
}
|
|
55
|
+
async function insertWithId(field) {
|
|
56
|
+
const value = init[field];
|
|
57
|
+
if (!value.length)
|
|
58
|
+
return { [field]: [] };
|
|
59
|
+
const result = (await tx
|
|
60
|
+
.insertInto(`contact_${field}`)
|
|
61
|
+
.values(value.map(item => ({ ...item, id })))
|
|
62
|
+
.returning(Object.keys(fieldSchemas[field].shape))
|
|
63
|
+
.execute());
|
|
64
|
+
return { [field]: result };
|
|
65
|
+
}
|
|
66
|
+
const result = await Promise.all([
|
|
67
|
+
insertWithId('addresses'),
|
|
68
|
+
insertWithId('emails'),
|
|
69
|
+
insertWithId('phones'),
|
|
70
|
+
insertWithId('dates'),
|
|
71
|
+
insertWithId('relationships'),
|
|
72
|
+
insertWithId('custom'),
|
|
73
|
+
]);
|
|
74
|
+
return Object.assign(...result);
|
|
75
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './db.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './db.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { checkImageUpload } from '@axium/server/api/images';
|
|
2
|
+
import { checkAuthForUser } from '@axium/server/auth';
|
|
3
|
+
import { database } from '@axium/server/database';
|
|
4
|
+
import { error, withError } from '@axium/server/requests';
|
|
5
|
+
import { addRoute } from '@axium/server/routes';
|
|
6
|
+
import { sql } from 'kysely';
|
|
7
|
+
import * as z from 'zod';
|
|
8
|
+
addRoute({
|
|
9
|
+
path: '/raw/contacts/pfp/:id',
|
|
10
|
+
params: { id: z.uuid() },
|
|
11
|
+
async HEAD(request, { id }) {
|
|
12
|
+
const { userId } = await database
|
|
13
|
+
.selectFrom('contacts')
|
|
14
|
+
.select('userId')
|
|
15
|
+
.where('id', '=', id)
|
|
16
|
+
.executeTakeFirstOrThrow()
|
|
17
|
+
.catch(withError('Contact does not exist', 404));
|
|
18
|
+
await checkAuthForUser(request, userId);
|
|
19
|
+
const pfp = await database.selectFrom('contact_pictures').selectAll().where('contactId', '=', id).executeTakeFirst();
|
|
20
|
+
if (!pfp)
|
|
21
|
+
error(404, 'Contact picture not found');
|
|
22
|
+
return new Response(null, {
|
|
23
|
+
headers: {
|
|
24
|
+
'content-type': pfp.type,
|
|
25
|
+
'content-length': pfp.data.length.toString(),
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
},
|
|
29
|
+
async GET(request, { id }) {
|
|
30
|
+
const { userId } = await database
|
|
31
|
+
.selectFrom('contacts')
|
|
32
|
+
.select('userId')
|
|
33
|
+
.where('id', '=', id)
|
|
34
|
+
.executeTakeFirstOrThrow()
|
|
35
|
+
.catch(withError('Contact does not exist', 404));
|
|
36
|
+
const pfp = await database.selectFrom('contact_pictures').selectAll().where('contactId', '=', id).executeTakeFirst();
|
|
37
|
+
if (!pfp)
|
|
38
|
+
error(404, 'Contact picture not found');
|
|
39
|
+
await checkAuthForUser(request, userId);
|
|
40
|
+
return new Response(pfp.data, {
|
|
41
|
+
headers: {
|
|
42
|
+
'content-type': pfp.type,
|
|
43
|
+
'content-length': pfp.data.length.toString(),
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
async POST(request, { id }) {
|
|
48
|
+
const { userId } = await database
|
|
49
|
+
.selectFrom('contacts')
|
|
50
|
+
.select('userId')
|
|
51
|
+
.where('id', '=', id)
|
|
52
|
+
.executeTakeFirstOrThrow()
|
|
53
|
+
.catch(withError('Contact does not exist', 404));
|
|
54
|
+
const { data, type } = await checkImageUpload(request, { enabled: true, max_size: 500, max_length: 1000 }, userId);
|
|
55
|
+
const { isInsert } = await database
|
|
56
|
+
.insertInto('contact_pictures')
|
|
57
|
+
.values({ contactId: id, data, type })
|
|
58
|
+
.onConflict(oc => oc.column('contactId').doUpdateSet({ data, type }))
|
|
59
|
+
.returning(sql `xmax = 0`.as('isInsert'))
|
|
60
|
+
.executeTakeFirstOrThrow()
|
|
61
|
+
.catch(withError('Failed to upload contact picture', 500));
|
|
62
|
+
return new Response(null, { status: isInsert ? 201 : 200 });
|
|
63
|
+
},
|
|
64
|
+
async DELETE(request, { id }) {
|
|
65
|
+
const { userId } = await database
|
|
66
|
+
.selectFrom('contacts')
|
|
67
|
+
.select('userId')
|
|
68
|
+
.where('id', '=', id)
|
|
69
|
+
.executeTakeFirstOrThrow()
|
|
70
|
+
.catch(withError('Contact does not exist', 404));
|
|
71
|
+
await checkAuthForUser(request, userId);
|
|
72
|
+
const result = await database
|
|
73
|
+
.deleteFrom('contact_pictures')
|
|
74
|
+
.where('contactId', '=', id)
|
|
75
|
+
.executeTakeFirst()
|
|
76
|
+
.catch(withError('Failed to delete contact picture', 500));
|
|
77
|
+
if (!result?.numDeletedRows)
|
|
78
|
+
error(404, 'Contact picture not found');
|
|
79
|
+
return new Response(null, { status: 204 });
|
|
80
|
+
},
|
|
81
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { NoExternal } from '@axium/contacts';
|
|
3
|
+
import { format } from '@axium/contacts/client';
|
|
4
|
+
|
|
5
|
+
const { contact, compact }: { contact: NoExternal; compact?: boolean } = $props();
|
|
6
|
+
|
|
7
|
+
const name = format.name(contact);
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<a href="/contacts/{contact.id}">{name}</a>
|
|
11
|
+
|
|
12
|
+
<!-- @todo show expanded card on hover
|
|
13
|
+
<div class="ContactCard">
|
|
14
|
+
<ContactPicture {contact} />
|
|
15
|
+
<h3>{name}</h3>
|
|
16
|
+
<span>{format.emailDefault(contact)}</span>
|
|
17
|
+
</div>
|
|
18
|
+
-->
|
|
19
|
+
|
|
20
|
+
<style>
|
|
21
|
+
</style>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { InitNoExternal } from '@axium/contacts';
|
|
3
|
+
import { colorHashRGB } from '@axium/core/color';
|
|
4
|
+
import { name as formatName } from '@axium/contacts/client/format';
|
|
5
|
+
import UserPFP from '@axium/client/components/UserPFP';
|
|
6
|
+
import { userInfo } from '@axium/client';
|
|
7
|
+
|
|
8
|
+
let { contact, isDefault = $bindable() }: { contact: InitNoExternal & { id: string }; isDefault?: boolean } = $props();
|
|
9
|
+
|
|
10
|
+
const name = $derived(formatName(contact));
|
|
11
|
+
|
|
12
|
+
const defaultImage = $derived(
|
|
13
|
+
`data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 50" style="background-color:${colorHashRGB(name ?? '\0')};display:flex;align-items:center;justify-content:center;">
|
|
14
|
+
<text x="23" y="28" style="font-family:sans-serif;font-weight:bold;" fill="white">${(name.replaceAll(/\W/g, '') || '?')[0]}</text>
|
|
15
|
+
</svg>`.replaceAll(/[\t\n]/g, '')
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
let src = $state(`/raw/contacts/pfp/${contact.id}`);
|
|
19
|
+
</script>
|
|
20
|
+
|
|
21
|
+
{#if contact.linkedUserId}
|
|
22
|
+
<UserPFP user={await userInfo(contact.linkedUserId)} bind:isDefault />
|
|
23
|
+
{:else}
|
|
24
|
+
<img
|
|
25
|
+
class="ContactPicture"
|
|
26
|
+
{src}
|
|
27
|
+
alt={name}
|
|
28
|
+
onerror={() => {
|
|
29
|
+
isDefault = true;
|
|
30
|
+
src = defaultImage;
|
|
31
|
+
}}
|
|
32
|
+
/>
|
|
33
|
+
{/if}
|
|
34
|
+
|
|
35
|
+
<style>
|
|
36
|
+
img.ContactPicture {
|
|
37
|
+
width: var(--size, 2em);
|
|
38
|
+
height: var(--size, 2em);
|
|
39
|
+
border-radius: 50%;
|
|
40
|
+
border: 1px solid #8888;
|
|
41
|
+
vertical-align: middle;
|
|
42
|
+
margin-right: 0.5em;
|
|
43
|
+
/* see https://drafts.csswg.org/css-image-animation-1/ */
|
|
44
|
+
image-animation: stopped;
|
|
45
|
+
}
|
|
46
|
+
</style>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { currentMonthNames, dateField } from '@axium/client';
|
|
3
|
+
|
|
4
|
+
let { day = $bindable<number>(), month = $bindable<number>(), year = $bindable<number>() } = $props();
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<div class="ContactDateSelect">
|
|
8
|
+
<select bind:value={month}>
|
|
9
|
+
<option value="" disabled selected>{dateField('month')}</option>
|
|
10
|
+
{#each currentMonthNames as month, i}
|
|
11
|
+
<option value={i + 1}>{month}</option>
|
|
12
|
+
{/each}
|
|
13
|
+
</select>
|
|
14
|
+
|
|
15
|
+
<input type="number" bind:value={day} min="1" max="31" size="3" placeholder={dateField('day')} />
|
|
16
|
+
<input type="number" bind:value={year} min="0" max="9999" size="4" placeholder={dateField('year')} />
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<style>
|
|
20
|
+
.ContactDateSelect {
|
|
21
|
+
display: flex;
|
|
22
|
+
gap: 0.5em;
|
|
23
|
+
align-items: center;
|
|
24
|
+
}
|
|
25
|
+
</style>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { text } from '@axium/client';
|
|
3
|
+
import { fetchAPI } from '@axium/client/requests';
|
|
4
|
+
import type { NoExternal } from '@axium/contacts';
|
|
5
|
+
import { format } from '@axium/contacts/client';
|
|
6
|
+
import { errorText } from 'ioium';
|
|
7
|
+
|
|
8
|
+
const { onSelect, exclude = [] }: { onSelect(id: string): unknown; exclude?: string[] } = $props();
|
|
9
|
+
|
|
10
|
+
let results = $state<NoExternal[]>([]),
|
|
11
|
+
value = $state<string>(),
|
|
12
|
+
gotError = $state<boolean>(false);
|
|
13
|
+
|
|
14
|
+
async function oninput() {
|
|
15
|
+
if (!value || !value.length) {
|
|
16
|
+
results = [];
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
results = await fetchAPI('POST', 'contact-discovery', value);
|
|
22
|
+
} catch (e) {
|
|
23
|
+
gotError = true;
|
|
24
|
+
console.warn('Can not use contact discovery:', errorText(e));
|
|
25
|
+
results = [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function select(target: NoExternal) {
|
|
30
|
+
return (e: Event) => {
|
|
31
|
+
e.stopPropagation();
|
|
32
|
+
onSelect(target.id);
|
|
33
|
+
results = [];
|
|
34
|
+
value = format.name(target);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
</script>
|
|
38
|
+
|
|
39
|
+
<input bind:value type="text" placeholder={text('contacts.Discovery.placeholder')} {oninput} />
|
|
40
|
+
{#if !gotError && value}
|
|
41
|
+
<!-- Don't show results when we can't use the discovery API -->
|
|
42
|
+
<div class="results">
|
|
43
|
+
{#each results as result}
|
|
44
|
+
{#if !exclude.includes(result.id)}
|
|
45
|
+
<div class="result" onclick={select(result)}>
|
|
46
|
+
<span>{format.name(result)}</span>
|
|
47
|
+
</div>
|
|
48
|
+
{/if}
|
|
49
|
+
{:else}
|
|
50
|
+
<i>{text('contacts.Discovery.no_results')}</i>
|
|
51
|
+
{/each}
|
|
52
|
+
</div>
|
|
53
|
+
{/if}
|
|
54
|
+
|
|
55
|
+
<style>
|
|
56
|
+
:host {
|
|
57
|
+
anchor-scope: --discovery-input;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
input {
|
|
61
|
+
anchor-name: --discovery-input;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
input:focus + .results,
|
|
65
|
+
.results:active {
|
|
66
|
+
display: flex;
|
|
67
|
+
animation: var(--A-zoom);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
.results {
|
|
71
|
+
position: fixed;
|
|
72
|
+
position-anchor: --discovery-input;
|
|
73
|
+
inset: anchor(bottom) anchor(right) auto anchor(left);
|
|
74
|
+
display: none;
|
|
75
|
+
flex-direction: column;
|
|
76
|
+
gap: 0.25em;
|
|
77
|
+
height: fit-content;
|
|
78
|
+
max-height: 25em;
|
|
79
|
+
background-color: var(--bg-accent);
|
|
80
|
+
border-radius: 0.25em 0.25em 0.75em 0.75em;
|
|
81
|
+
padding: 1em;
|
|
82
|
+
border: var(--border-accent);
|
|
83
|
+
align-items: stretch;
|
|
84
|
+
|
|
85
|
+
i {
|
|
86
|
+
text-align: center;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.result {
|
|
91
|
+
padding: 0.25em 0.75em;
|
|
92
|
+
border-radius: 0.5em;
|
|
93
|
+
display: inline-flex;
|
|
94
|
+
align-items: center;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
.result:hover {
|
|
98
|
+
cursor: pointer;
|
|
99
|
+
background-color: var(--bg-strong);
|
|
100
|
+
}
|
|
101
|
+
</style>
|