initial commit

This commit is contained in:
felix
2026-09-16 17:16:19 +02:00
commit d120237521
80 changed files with 26582 additions and 0 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.git
dist
tmp
.eslintcache

19
.editorconfig Normal file
View File

@@ -0,0 +1,19 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# https://editorconfig.org/
root = true
[*]
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
indent_style = space
indent_size = 2
[*.hbs]
insert_final_newline = false
[*.{diff,md}]
trim_trailing_whitespace = false

19
.ember-cli Normal file
View File

@@ -0,0 +1,19 @@
{
/**
Setting `isTypeScriptProject` to true will force the blueprint generators to generate TypeScript
rather than JavaScript by default, when a TypeScript version of a given blueprint is available.
*/
"isTypeScriptProject": false,
/**
Setting `componentAuthoringFormat` to "strict" will force the blueprint generators to generate GJS
or GTS files for the component and the component rendering test. "loose" is the default.
*/
"componentAuthoringFormat": "strict",
/**
Setting `routeAuthoringFormat` to "strict" will force the blueprint generators to generate GJS
or GTS templates for routes. "loose" is the default
*/
"routeAuthoringFormat": "strict"
}

1
.env.development Normal file
View File

@@ -0,0 +1 @@
PROXY=http://localhost:90

18
.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
# compiled output
/dist/
/tmp/
# dependencies
/node_modules/
# misc
*.local
/.pnp*
/.eslintcache
/coverage/
/npm-debug.log*
/testem.log
/yarn-error.log
# broccoli-debug
/DEBUG/

14
.prettierignore Normal file
View File

@@ -0,0 +1,14 @@
# unconventional js
/blueprints/*/files/
# compiled output
/dist/
# misc
/coverage/
!.*
.*/
/pnpm-lock.yaml
ember-cli-update.json
*.html
CHANGELOG.md

12
.prettierrc.mjs Normal file
View File

@@ -0,0 +1,12 @@
export default {
plugins: ['prettier-plugin-ember-template-tag'],
overrides: [
{
files: '*.{js,gjs,ts,gts,mjs,mts,cjs,cts}',
options: {
singleQuote: true,
templateSingleQuote: false,
},
},
],
};

5
.stylelintignore Normal file
View File

@@ -0,0 +1,5 @@
# unconventional files
/blueprints/*/files/
# compiled output
/dist/

3
.stylelintrc.mjs Normal file
View File

@@ -0,0 +1,3 @@
export default {
extends: ['stylelint-config-standard'],
};

3
.template-lintrc.mjs Normal file
View File

@@ -0,0 +1,3 @@
export default {
extends: 'recommended',
};

3
.watchmanconfig Normal file
View File

@@ -0,0 +1,3 @@
{
"ignore_dirs": ["dist"]
}

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:24 AS builder
LABEL maintainer="info@redpencil.io"
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM semtech/static-file-service:0.2.0
COPY --from=builder /app/dist /data

View File

@@ -0,0 +1,28 @@
import { JSONAPIAdapter } from '@warp-drive/legacy/adapter/json-api';
export default class ApplicationAdapter extends JSONAPIAdapter {
ajax(url, method) {
if (method !== 'GET') return super.ajax(...arguments);
return retryOnError(super.ajax.bind(this), arguments);
}
}
async function retryOnError(ajax, ajaxArgs, retryCount = 0) {
const MAX_RETRIES = 6;
try {
return await ajax(...ajaxArgs);
} catch (error) {
if (retryCount < MAX_RETRIES) {
await sleep(250 * (retryCount + 1));
return retryOnError(ajax, ajaxArgs, retryCount + 1);
} else {
throw error;
}
}
}
function sleep(time) {
return new Promise((resolve) => setTimeout(() => resolve(true), time));
}

23
app/app.js Normal file
View File

@@ -0,0 +1,23 @@
import './styles/app.scss';
import '@warp-drive/ember/install';
import Application from '@ember/application';
import compatModules from '@embroider/virtual/compat-modules';
import Resolver from 'ember-resolver';
import loadInitializers from 'ember-load-initializers';
import './config/custom-inflector-rules';
import config from 'frontend-lblod-chat/config/environment';
import { importSync, isDevelopingApp, macroCondition } from '@embroider/macros';
import setupInspector from '@embroider/legacy-inspector-support/ember-source-4.12';
if (macroCondition(isDevelopingApp())) {
importSync('./deprecation-workflow');
}
export default class App extends Application {
modulePrefix = config.modulePrefix;
podModulePrefix = config.podModulePrefix;
Resolver = Resolver.withModules(compatModules);
inspector = setupInspector(this);
}
loadInitializers(App, config.modulePrefix, compatModules);

View File

@@ -0,0 +1,14 @@
{{! A loader while the file is being made, a download link when it is ready }}
<div class="chat-attachment au-u-margin-top-tiny">
{{#if @document.url}}
<a href={{@document.url}} class="au-c-link" download={{@document.name}}>
{{@document.name}}
</a>
{{else if @late}}
<AuAlert @skin="warning" @size="small" @title={{@document.name}}>
Dit duurt langer dan verwacht. Vernieuw de pagina om opnieuw te kijken.
</AuAlert>
{{else}}
<AuLoader @inline={{true}}>{{@document.name}} wordt gemaakt</AuLoader>
{{/if}}
</div>

View File

@@ -0,0 +1,18 @@
<div class="chat-composer au-u-flex au-u-flex--between">
<AuTextarea
@width="block"
@disabled={{@disabled}}
rows="2"
placeholder="Stel een vraag"
value={{this.draft}}
{{on "input" this.update}}
{{on "keydown" this.keydown}}
/>
<AuButton
@disabled={{@disabled}}
class="au-u-margin-left-small"
{{on "click" this.submit}}
>
Verstuur
</AuButton>
</div>

View File

@@ -0,0 +1,25 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class ChatComposer extends Component {
@tracked draft = '';
@action update(event) {
this.draft = event.target.value;
}
@action keydown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.submit();
}
}
@action submit() {
const content = this.draft.trim();
if (!content || this.args.disabled) return;
this.args.onSend(content);
this.draft = '';
}
}

View File

@@ -0,0 +1,10 @@
<div
class="chat-message
{{if @isAssistant 'chat-message--assistant' 'chat-message--user'}}"
>
<this.body
@message={{@message}}
@isAssistant={{@isAssistant}}
@now={{@now}}
/>
</div>

View File

@@ -0,0 +1,11 @@
import Component from '@glimmer/component';
import { componentFor } from 'frontend-lblod-chat/utils/message-types';
export default class ChatMessage extends Component {
get body() {
return componentFor(
this.args.message.constructor.modelName,
this.args.isAssistant,
);
}
}

View File

@@ -0,0 +1,6 @@
{{! Rendered markdown. Assistant content only; the user's own text stays
plain. The value is sanitized (DOMPurify) and marked safe in the js class. }}
<div class="chat-md">{{this.html}}</div>
{{#each @message.attachments as |document|}}
<Chat::Attachment @document={{document}} @late={{this.late}} />
{{/each}}

View File

@@ -0,0 +1,11 @@
import Component from '@glimmer/component';
import { htmlSafe } from '@ember/template';
import { renderMarkdown } from 'frontend-lblod-chat/utils/markdown';
export default class AssistantMessage extends Component {
// The string is DOMPurify-sanitized in utils/markdown; htmlSafe only marks
// it as rendered HTML.
get html() {
return htmlSafe(renderMarkdown(this.args.message.content));
}
}

View File

@@ -0,0 +1,4 @@
{{@message.content}}
{{#each @message.attachments as |document|}}
<Chat::Attachment @document={{document}} @late={{this.late}} />
{{/each}}

View File

@@ -0,0 +1,10 @@
import Component from '@glimmer/component';
import { FILE_CEILING_MS } from 'frontend-lblod-chat/utils/ceilings';
export default class InstantMessage extends Component {
get late() {
return (
this.args.now - this.args.message.created.getTime() >= FILE_CEILING_MS
);
}
}

View File

@@ -0,0 +1,39 @@
{{! The list, the composer, the poll loop }}
<div class="chat-window">
<div class="chat-window__messages">
{{#unless this.messages.length}}
<p class="au-u-muted">{{@assistant.description}}</p>
{{/unless}}
{{#each this.messages key="id" as |message|}}
<Chat::Message
@message={{message}}
@isAssistant={{this.isAssistant message}}
@now={{this.now}}
/>
{{/each}}
{{#if this.waitingForAnswer}}
<AuLoader @inline={{true}}>{{@assistant.name}} denkt na</AuLoader>
{{/if}}
{{#if this.answerTimedOut}}
<AuAlert @skin="warning" @title="Geen antwoord">
De assistent heeft niet geantwoord.
<AuButton @skin="link" {{on "click" this.refresh}}>Vernieuwen</AuButton>
</AuAlert>
{{/if}}
{{#if this.send.last.isError}}
<AuAlert
@skin="error"
@title="Niet verstuurd"
>{{this.send.last.error.message}}</AuAlert>
{{/if}}
</div>
<Chat::Composer
@onSend={{perform this.send}}
@disabled={{this.waitingForAnswer}}
/>
</div>

View File

@@ -0,0 +1,123 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { task, restartableTask, timeout } from 'ember-concurrency';
import ENV from 'frontend-lblod-chat/config/environment';
import { setting } from 'frontend-lblod-chat/utils/setting';
import {
ANSWER_CEILING_MS,
FILE_CEILING_MS,
} from 'frontend-lblod-chat/utils/ceilings';
const POLL_MS = 3000;
export default class ChatWindow extends Component {
@service store;
@tracked now = Date.now();
assistantPath = setting(ENV.assistantPath, '/assistant');
constructor() {
super(...arguments);
this.poll.perform();
}
get conversation() {
return this.args.conversation;
}
get messages() {
return this.conversation.messages
.slice()
.sort((a, b) => a.created - b.created);
}
get last() {
return this.messages.at(-1);
}
isAssistant = (message) => message.maker === this.args.assistant?.uri;
age(date) {
return this.now - date.getTime();
}
// Waiting for an answer: the newest message is the user's.
get waitingForAnswer() {
return (
!!this.last &&
!this.isAssistant(this.last) &&
this.age(this.last.created) < ANSWER_CEILING_MS
);
}
get answerTimedOut() {
return (
!!this.last &&
!this.isAssistant(this.last) &&
this.age(this.last.created) >= ANSWER_CEILING_MS
);
}
// Waiting for a file: an attachment has no url yet.
get waitingForFile() {
return this.messages.some(
(m) =>
this.age(m.created) < FILE_CEILING_MS &&
m.attachments.some((a) => !a.url),
);
}
get waiting() {
return this.waitingForAnswer || this.waitingForFile;
}
// One loop. Runs while there is something to wait for, and not otherwise.
poll = restartableTask(async () => {
while (this.waiting) {
await timeout(POLL_MS);
await this.reload();
}
});
async reload() {
await this.store.findRecord('chat-conversation', this.conversation.id, {
reload: true,
include: 'messages.attachments',
});
this.now = Date.now();
}
send = task(async (content) => {
const res = await fetch(
`${this.assistantPath}/conversations/${this.conversation.id}/turns`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ content }),
},
);
if (!res.ok)
throw new Error(`De vraag kon niet verstuurd worden (${res.status}).`);
// The 202 answers with the message id, but fetching that message alone
// does not link it into the conversation: the resource sends relationships
// as links, without data. Reload the conversation instead, so the question
// shows now and `waiting` turns true for the poll loop.
try {
await this.reload();
} catch {
// the poll loop retries the reload
}
this.poll.perform();
});
@action
async refresh() {
await this.reload();
this.poll.perform();
}
}

View File

@@ -0,0 +1,10 @@
import { irregular } from '@warp-drive/utilities/string';
irregular('bestuurseenheid', 'bestuurseenheden');
irregular('bestuursorgaan', 'bestuursorganen');
irregular('werkingsgebied', 'werkingsgebieden');
irregular('form-data', 'form-data');
// TODO: find out why we do it like this instead of renaming the model?
irregular('address', 'adressen');
irregular('site', 'vestigingen');

27
app/config/environment.js Normal file
View File

@@ -0,0 +1,27 @@
import loadConfigFromMeta from '@embroider/config-meta-loader';
import { assert } from '@ember/debug';
const config = loadConfigFromMeta('frontend-lblod-chat');
assert(
'config is not an object',
typeof config === 'object' && config !== null,
);
assert(
'modulePrefix was not detected on your config',
'modulePrefix' in config && typeof config.modulePrefix === 'string',
);
assert(
'locationType was not detected on your config',
'locationType' in config && typeof config.locationType === 'string',
);
assert(
'rootURL was not detected on your config',
'rootURL' in config && typeof config.rootURL === 'string',
);
assert(
'APP was not detected on your config',
'APP' in config && typeof config.APP === 'object',
);
export default config;

View File

@@ -0,0 +1,11 @@
import Controller from '@ember/controller';
import { service } from '@ember/service';
import { action } from '@ember/object';
export default class ApplicationController extends Controller {
@service session;
@action logout() {
this.session.invalidate();
}
}

View File

@@ -0,0 +1,20 @@
import Controller from '@ember/controller';
import { service } from '@ember/service';
import { task } from 'ember-concurrency';
export default class ConversationsController extends Controller {
@service store;
@service router;
@service currentSession;
create = task(async () => {
const now = new Date();
const conversation = this.store.createRecord('chat-conversation', {
created: now,
lastActivity: now,
creator: this.currentSession.user,
});
await conversation.save();
this.router.transitionTo('conversation', conversation.id);
});
}

View File

@@ -0,0 +1,31 @@
import Controller from '@ember/controller';
import { service } from '@ember/service';
import { restartableTask, task, timeout } from 'ember-concurrency';
import { tracked } from '@glimmer/tracking';
export default class MockLoginController extends Controller {
queryParams = ['gemeente', 'page'];
@service store;
@tracked gemeente = '';
@tracked page = 0;
size = 10;
queryStore = task(async () => {
const filter = { provider: 'https://github.com/lblod/mock-login-service' };
if (this.gemeente) filter.gebruiker = { achternaam: this.gemeente };
const accounts = await this.store.query('account', {
include: 'gebruiker,gebruiker.bestuurseenheden',
filter: filter,
page: { size: this.size, number: this.page },
sort: 'gebruiker.achternaam',
});
return accounts;
});
updateSearch = restartableTask(async (value) => {
await timeout(500);
this.page = 0;
this.gemeente = value;
this.model = await this.queryStore.perform();
});
}

View File

@@ -0,0 +1,42 @@
import setupDeprecationWorkflow from 'ember-cli-deprecation-workflow';
/**
* Docs: https://github.com/ember-cli/ember-cli-deprecation-workflow
*/
setupDeprecationWorkflow({
/**
false by default, but if a developer / team wants to be more aggressive about being proactive with
handling their deprecations, this should be set to "true"
*/
throwOnUnhandled: false,
workflow: [
/* ... handlers ... */
/* to generate this list, run your app for a while (or run the test suite),
* and then run in the browser console:
*
* deprecationWorkflow.flushDeprecations()
*
* And copy the handlers here
*/
/* example: */
/* { handler: 'silence', matchId: 'template-action' }, */
// TODO: resolve these before updating to WarpDrive v6
{
handler: 'silence',
matchId: 'warp-drive:deprecate-legacy-request-methods',
},
// TODO: resolve these before updating to ember-power-select v9
{
handler: 'silence',
matchId: 'ember-power-select.deprecate-power-select-multiple',
},
{
handler: 'silence',
matchId: 'ember-power-select.deprecate-power-select-multiple-trigger',
},
{
handler: 'silence',
matchId: 'ember-power-select.deprecate-power-select-multiple-input',
},
],
});

5
app/helpers/app-name.js Normal file
View File

@@ -0,0 +1,5 @@
import { helper } from '@ember/component/helper';
import ENV from 'frontend-lblod-chat/config/environment';
import { setting } from 'frontend-lblod-chat/utils/setting';
export default helper(() => setting(ENV.appName, 'Assistent'));

12
app/models/account.js Normal file
View File

@@ -0,0 +1,12 @@
import Model, { attr, belongsTo } from '@warp-drive/legacy/model';
export default class Account extends Model {
@attr() voId;
@attr() provider;
@belongsTo('gebruiker', {
async: true,
inverse: null,
})
gebruiker;
}

View File

@@ -0,0 +1,7 @@
import Model, { attr } from '@warp-drive/legacy/model';
export default class BestuurseenheidClassificatieCode extends Model {
@attr() label;
@attr() scopeNote;
@attr() uri;
}

View File

@@ -0,0 +1,15 @@
import Model, { attr, belongsTo } from '@warp-drive/legacy/model';
export default class Bestuurseenheid extends Model {
@attr() naam;
@belongsTo('bestuurseenheid-classificatie-code', {
async: true,
inverse: null,
})
classificatie;
get fullName() {
return `${this.classificatie.get('label')} ${this.naam}`.trim();
}
}

7
app/models/chat-agent.js Normal file
View File

@@ -0,0 +1,7 @@
import Model, { attr } from '@warp-drive/legacy/model';
export default class ChatAgent extends Model {
@attr uri;
@attr name;
@attr description;
}

View File

@@ -0,0 +1,16 @@
import Model, { attr, belongsTo, hasMany } from '@warp-drive/legacy/model';
export default class ChatConversation extends Model {
@attr uri;
@attr title;
@attr('date') created;
@attr('date') lastActivity;
@belongsTo('gebruiker', { async: true, inverse: null }) creator;
@hasMany('chat-message', {
async: false,
inverse: 'conversation',
polymorphic: true,
})
messages;
}

View File

@@ -0,0 +1,8 @@
import Model, { attr } from '@warp-drive/legacy/model';
export default class ChatDocument extends Model {
@attr uri;
@attr name;
@attr mediaType;
@attr url;
}

View File

@@ -0,0 +1,3 @@
import ChatMessage from './chat-message';
export default class ChatInstantMessage extends ChatMessage {}

View File

@@ -0,0 +1,16 @@
import Model, { attr, belongsTo, hasMany } from '@warp-drive/legacy/model';
export default class ChatMessage extends Model {
@attr uri;
@attr content;
@attr('date') created;
@attr maker;
@belongsTo('chat-conversation', {
async: false,
inverse: 'messages',
as: 'chat-message',
})
conversation;
@hasMany('chat-document', { async: false, inverse: null }) attachments;
}

27
app/models/gebruiker.js Normal file
View File

@@ -0,0 +1,27 @@
import Model, { attr, hasMany } from '@warp-drive/legacy/model';
export default class Gebruiker extends Model {
@attr uri;
@attr() voornaam;
@attr() achternaam;
@hasMany('account', {
async: true,
inverse: null,
})
account;
@hasMany('bestuurseenheid', {
async: true,
inverse: null,
})
bestuurseenheden;
get group() {
return this.hasMany('bestuurseenheden').value()[0];
}
get fullName() {
return `${this.voornaam} ${this.achternaam}`.trim();
}
}

16
app/router.js Normal file
View File

@@ -0,0 +1,16 @@
import EmberRouter from '@embroider/router';
import config from 'frontend-lblod-chat/config/environment';
export default class Router extends EmberRouter {
location = config.locationType;
rootURL = config.rootURL;
}
Router.map(function () {
this.route('login');
this.route('mock-login');
this.route('authorization-callback', { path: '/authorization/callback' });
this.route('conversations');
this.route('conversation', { path: '/conversations/:id' });
this.route('route-not-found', { path: '/*wildcard' });
});

21
app/routes/application.js Normal file
View File

@@ -0,0 +1,21 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { warn } from '@ember/debug';
export default class ApplicationRoute extends Route {
@service router;
@service session;
@service currentSession;
async beforeModel() {
await this.session.setup();
return this._loadCurrentSession();
}
_loadCurrentSession() {
return this.currentSession.load().catch((e) => {
warn(e, { id: 'session-load-failure' });
this.session.invalidate();
});
}
}

View File

@@ -0,0 +1,14 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class AuthorizationCallbackRoute extends Route {
@service session;
beforeModel() {
this.session.prohibitAuthentication('index');
}
model(params) {
return this.session.authenticate('authenticator:acm-idm', params.code);
}
}

View File

@@ -0,0 +1,22 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class ConversationRoute extends Route {
@service session;
@service store;
beforeModel(transition) {
this.session.requireAuthentication(transition, 'login');
}
async model({ id }) {
const [conversation, agents] = await Promise.all([
this.store.findRecord('chat-conversation', id, {
include: 'messages.attachments',
reload: true,
}),
this.store.query('chat-agent', { 'page[size]': 1 }),
]);
return { conversation, assistant: agents[0] };
}
}

View File

@@ -0,0 +1,18 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class ConversationsRoute extends Route {
@service session;
@service store;
beforeModel(transition) {
this.session.requireAuthentication(transition, 'login');
}
model() {
return this.store.query('chat-conversation', {
sort: '-last-activity',
'page[size]': 50,
});
}
}

13
app/routes/index.js Normal file
View File

@@ -0,0 +1,13 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class IndexRoute extends Route {
@service router;
@service session;
beforeModel(transition) {
this.session.requireAuthentication(transition, 'login');
this.router.transitionTo('conversations');
super.beforeModel(...arguments);
}
}

10
app/routes/login.js Normal file
View File

@@ -0,0 +1,10 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class LoginRoute extends Route {
@service session;
beforeModel() {
this.session.prohibitAuthentication('index');
}
}

28
app/routes/mock-login.js Normal file
View File

@@ -0,0 +1,28 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class MockLoginRoute extends Route {
@service session;
@service store;
queryParams = {
page: {
refreshModel: true,
},
};
beforeModel() {
this.session.prohibitAuthentication('index');
}
model(params) {
const filter = { provider: 'https://github.com/lblod/mock-login-service' };
if (params.gemeente) filter.gebruiker = { achternaam: params.gemeente };
return this.store.query('account', {
include: 'gebruiker.bestuurseenheden',
filter: filter,
page: { size: 10, number: params.page },
sort: 'gebruiker.achternaam',
});
}
}

View File

@@ -0,0 +1,62 @@
import { JSONAPISerializer } from '@warp-drive/legacy/serializer/json-api';
export default class ApplicationSerializer extends JSONAPISerializer {
/**
Parse the links in the JSONAPI response and convert to a meta-object.
Inlined from the ember-data-table package.
*/
normalizeQueryResponse(store, clazz, payload) {
const result = super.normalizeQueryResponse(...arguments);
result.meta = result.meta || {};
if (payload.links) {
result.meta.pagination = createPageMeta(payload.links);
}
if (payload.meta) {
result.meta.count = payload.meta.count;
}
return result;
}
}
/**
Transforms link URLs to objects containing metadata
E.g.
{
previous: '/streets?page[number]=1&page[size]=10&sort=name
next: '/streets?page[number]=3&page[size]=10&sort=name
}
will be converted to
{
previous: { number: 1, size: 10 },
next: { number: 3, size: 10 }
}
*/
function createPageMeta(data) {
let meta = {};
Object.keys(data).forEach((type) => {
const link = data[type];
meta[type] = {};
if (link) {
//extracts from '/path?foo=bar&baz=foo' the string: foo=bar&baz=foo
const query = link.split(/\?(.+)/)[1] || '';
query.split('&').forEach((pairs) => {
const [param, value] = pairs.split('=');
if (decodeURIComponent(param) === 'page[number]') {
meta[type].number = parseInt(value);
} else if (decodeURIComponent(param) === 'page[size]') {
meta[type].size = parseInt(value);
}
});
}
});
return meta;
}

View File

@@ -0,0 +1,23 @@
import Service, { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class CurrentSessionService extends Service {
@service session;
@service store;
@tracked account;
get user() {
return this.account?.belongsTo('gebruiker').value();
}
async load() {
if (this.session.isAuthenticated) {
const accountId =
this.session.data.authenticated.relationships.account.data.id;
this.account = await this.store.findRecord('account', accountId, {
include: 'gebruiker',
});
}
}
}

83
app/services/session.js Normal file
View File

@@ -0,0 +1,83 @@
import { service } from '@ember/service';
import BaseSessionService from 'ember-simple-auth/services/session';
import ENV from 'frontend-lblod-chat/config/environment';
export default class SessionService extends BaseSessionService {
@service currentSession;
@service router;
get isAcmIdmSession() {
if (!this.isAuthenticated) {
return false;
}
return this.data.authenticated.authenticator === 'authenticator:acm-idm';
}
requireAuthentication(transition, routeOrCallback) {
const isAuthenticated = super.requireAuthentication(
transition,
routeOrCallback,
);
// The user isn't authenticated. We store the url in a cookie so we can retry it after logging in.
if (!isAuthenticated) {
// By default ESA only supports retrying transitions by looking at the `attemptedTransition` property.
// However, this property isn't persisted, so after a page reload it is gone, which is an issue for our redirect-based ACM/IDM setup.
// ESA also has a cookie-based redirect setup to support FastBoot. With a little bit of code we can hook into this system
// so the redirect also survives a page reload.
const COOKIE_NAME = 'ember_simple_auth-redirectTarget'; // This is the name that ESA uses: https://github.com/mainmatter/ember-simple-auth/blob/1404c501c2ba9c8c6c071b05f38d42a057522318/packages/ember-simple-auth/src/-internals/routing.js#L37
const redirectUrl = routeInfoUrl(transition.to, this.router);
document.cookie = `${COOKIE_NAME}=${redirectUrl};path=/;samesite=strict`;
}
return isAuthenticated;
}
handleAuthentication(routeAfterAuthentication) {
super.handleAuthentication(routeAfterAuthentication);
this.currentSession.load();
}
invalidate() {
// We store the flag here since the data is cleared before the handleInvalidation method is called.
this.wasAcmIdmSession = this.isAcmIdmSession;
super.invalidate(...arguments);
}
handleInvalidation() {
let logoutUrl;
if (this.wasAcmIdmSession) {
logoutUrl = ENV.acmidm.logoutUrl;
} else {
logoutUrl = this.router.urlFor('mock-login');
}
super.handleInvalidation(logoutUrl);
}
}
// This converts a RouteInfo instance to the corresponding url by collecting all
// the route params and query params and using the router.urlFor method.
// This is a public API reimplementation of the `transition.intent.url` property.
// More info: https://discord.com/channels/480462759797063690/624403666585124895/897832626616926218
function routeInfoUrl(routeInfo, routerService) {
let targetRoute = routeInfo.name;
let allRouteParamValues = [];
let allRouteQueryParams = {};
routeInfo.find((routeInfo) => {
routeInfo.paramNames.forEach((paramName) => {
let paramValue = routeInfo.params[paramName];
allRouteParamValues.push(paramValue);
allRouteQueryParams = {
...allRouteQueryParams,
...routeInfo.queryParams,
};
});
});
return routerService.urlFor(targetRoute, ...allRouteParamValues, {
queryParams: allRouteQueryParams,
});
}

View File

@@ -0,0 +1 @@
export { default } from 'ember-simple-auth/session-stores/adaptive';

132
app/styles/app.scss Normal file
View File

@@ -0,0 +1,132 @@
/* ==================================
# Chat
================================== */
// SETTINGS
@import "@appuniversum/ember-appuniversum/styles/a-settings";
// TOOLS
@import "@appuniversum/ember-appuniversum/styles/a-tools";
// GENERIC
@import "@appuniversum/ember-appuniversum/styles/a-generic";
// ELEMENTS
@import "@appuniversum/ember-appuniversum/styles/a-elements";
// OBJECTS
@import "@appuniversum/ember-appuniversum/styles/a-objects";
// COMPONENTS
@import "@appuniversum/ember-appuniversum/styles/a-components";
// PLUGINS
@import "@appuniversum/ember-appuniversum/styles/a-plugins";
// UTILITIES
@import "@appuniversum/ember-appuniversum/styles/a-utilities";
// CHAT
.chat-window {
display: flex;
flex-direction: column;
height: calc(100vh - 8rem);
}
.chat-window__messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.chat-message {
max-width: 48rem;
margin: 0 auto 1rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
white-space: pre-wrap;
}
.chat-message--user {
background: var(--au-blue-100);
margin-left: 8rem;
}
.chat-message--assistant {
background: var(--au-gray-100);
margin-right: 8rem;
}
.chat-composer {
border-top: 1px solid var(--au-gray-200);
padding: 1rem;
}
/* Rendered markdown (assistant messages) */
.chat-md > :first-child {
margin-top: 0;
}
.chat-md > :last-child {
margin-bottom: 0;
}
.chat-md p,
.chat-md ul,
.chat-md ol,
.chat-md blockquote,
.chat-md table {
margin: 0 0 0.5rem;
}
.chat-md ul,
.chat-md ol {
padding-left: 1.5rem;
}
.chat-md code {
background: var(--au-gray-200);
border-radius: 0.25rem;
padding: 0.1rem 0.3rem;
font-size: 0.9em;
}
.chat-md pre {
margin: 0 0 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--au-gray-200);
border-radius: 0.25rem;
overflow-x: auto;
}
.chat-md pre code {
background: none;
padding: 0;
}
.chat-md table {
border-collapse: collapse;
}
.chat-md th,
.chat-md td {
border: 1px solid var(--au-gray-300);
padding: 0.25rem 0.5rem;
text-align: left;
}
.chat-md blockquote {
border-left: 3px solid var(--au-gray-300);
padding-left: 0.75rem;
color: var(--au-gray-500);
}
/* The turtle spec, hidden behind a disclosure */
.chat-md__spec {
margin: 0 0 0.5rem;
}
.chat-md__spec summary {
cursor: pointer;
color: var(--au-gray-700);
font-size: 0.9em;
}
.chat-md__spec summary:hover {
color: var(--au-gray-600);
}
/* the SPARQL translation, inside the same disclosure */
.chat-md__spec-queries {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--au-gray-300, #ccc);
}
.chat-md__spec-queries .chat-md__queries-label {
margin: 0 0 0.25rem;
color: var(--au-gray-700);
font-size: 0.9em;
}

View File

@@ -0,0 +1,24 @@
{{page-title (app-name)}}
<AuApp>
<AuMainHeader @homeRoute="conversations" @appTitle={{(app-name)}}>
{{#if this.session.isAuthenticated}}
<li class="au-c-list-horizontal__item">
<AuButton
@skin="link"
@icon="logout"
@iconAlignment="left"
{{on "click" this.logout}}
>
Afmelden
</AuButton>
</li>
{{/if}}
</AuMainHeader>
<main id="main" class="au-c-main-container">
{{outlet}}
</main>
</AuApp>
<AuModalContainer />

View File

@@ -0,0 +1,6 @@
<AuBodyContainer>
<Chat::Window
@conversation={{@model.conversation}}
@assistant={{@model.assistant}}
/>
</AuBodyContainer>

View File

@@ -0,0 +1,22 @@
<AuBodyContainer @scroll={{true}}>
<div class="au-o-region au-o-layout au-o-layout--small">
<AuHeading @level="1" @skin="2">Gesprekken</AuHeading>
<AuButton
@loading={{this.create.isRunning}}
{{on "click" (perform this.create)}}
>
Nieuw gesprek
</AuButton>
<AuList class="au-u-margin-top" as |Item|>
{{#each @model as |conversation|}}
<Item>
<AuLink @route="conversation" @model={{conversation.id}}>
{{if conversation.title conversation.title "Nieuw gesprek"}}
</AuLink>
</Item>
{{else}}
<p class="au-u-muted">Nog geen gesprekken.</p>
{{/each}}
</AuList>
</div>
</AuBodyContainer>

1
app/templates/index.hbs Normal file
View File

@@ -0,0 +1 @@
{{outlet}}

7
app/templates/login.hbs Normal file
View File

@@ -0,0 +1,7 @@
<AuBodyContainer @scroll={{true}}>
<div class="au-o-region-large au-o-layout au-o-layout--small">
<AuHeading @level="1" @skin="1">{{(app-name)}}</AuHeading>
<p class="au-u-margin-bottom">Meld je aan om een gesprek te beginnen.</p>
<AuLink @route="mock-login" @skin="button">Meld je aan</AuLink>
</div>
</AuBodyContainer>

View File

@@ -0,0 +1,56 @@
<AuBodyContainer @scroll={{true}} itemprop="mainContentOfPage">
<div id="content">
<div class="au-o-region-large">
<div class="au-o-layout">
<AuHeading @skin="4">Kies een bestuurseenheid om mee in te loggen.</AuHeading>
<p class="au-u-margin-top-small au-u-margin-bottom">
<AuLabel for="bestuurseenheid-searchbox">Bestuurseenheid zoeken</AuLabel>
<input
id="bestuurseenheid-searchbox"
class="au-c-input au-c-input--block"
placeholder="Aalst, Berchem, &hellip;"
value={{this.gemeente}}
{{on "input" (perform this.updateSearch value="target.value")}}
/>
</p>
<MockLogin as |login|>
{{#if this.queryStore.isRunning}}
<AuLoader @padding="small" />
{{else}}
{{#if login.errorMessage}}
<AuAlert
@icon="alert-triangle"
@title={{login.errorMessage}}
@skin="warning"
/>
{{/if}}
{{#each this.model as |account|}}
<AuButton
class="au-u-flex au-u-flex--row au-u-flex--start au-c-card au-u-margin-bottom-small"
@skin="secondary"
@width="block"
@size="large"
type="button"
{{on
"click"
(fn login.login account.id account.gebruiker.group.id)
}}
>
{{account.gebruiker.voornaam}}
{{account.gebruiker.achternaam}}
</AuButton>
{{/each}}
<AuDataTableNumberPagination
@page={{this.page}}
@size={{this.size}}
@nbOfItems={{this.model.length}}
@total={{this.model.meta.count}}
@links={{this.model.meta.pagination}}
/>
{{/if}}
</MockLogin>
</div>
</div>
</div>
</AuBodyContainer>

View File

@@ -0,0 +1 @@
{{outlet}}

4
app/utils/ceilings.js Normal file
View File

@@ -0,0 +1,4 @@
// The two ceilings, in one place. The window stops polling past them; the
// file card shows its warning past its own.
export const ANSWER_CEILING_MS = 10 * 60 * 1000;
export const FILE_CEILING_MS = 30 * 60 * 1000;

76
app/utils/markdown.js Normal file
View File

@@ -0,0 +1,76 @@
// Assistant messages are markdown. markdown-it renders, DOMPurify strips
// what should not reach the DOM (html: false already drops raw html in the
// source; sanitize is the second layer), and every link it leaves opens in
// a new tab.
//
// The spec is hidden behind a "Technische details" disclosure (task 10): a
// ```turtle (or any other language-tagged) block becomes
// <details class="chat-md__spec"> instead of a plain code block.
import DOMPurify from 'dompurify';
import MarkdownIt from 'markdown-it';
// Every link opens in a new tab (task 10 subtask 1). DOMPurify drops a
// target it did not set, so the attribute is added on sanitized output in
// the hook; rel protects any opened page from switching this tab's
// location (window.opener attacks).
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
const SPEC_LABEL = 'Technische details';
const QUERIES_LABEL = 'De SPARQL-query\'s die het rapport ophaalt';
const QUERIES_CLASS = 'chat-md__spec-queries';
const md = new MarkdownIt({ html: false, linkify: true, breaks: true });
// One renderer for both fenced and indented code: the class hooks the spec
// styles and keeps every block consistent. Indented blocks have no language,
// and an indented spec is not shown as technical details.
md.renderer.rules.fence = (tokens, idx) => {
const token = tokens[idx];
const lang = token.info.trim();
if (!lang) return codeBlock(token.content);
if (lang === 'sparql') return queriesBlock(token.content);
return specBlock(token.content);
};
md.renderer.rules.code_block = (tokens, idx) => codeBlock(tokens[idx].content);
function codeBlock(content) {
return `<pre><code class="chat-md__code">${md.utils.escapeHtml(
content,
)}</code></pre>\n`;
}
function specBlock(content) {
return `<details class="chat-md__spec"><summary>${SPEC_LABEL}</summary>` +
`<pre><code class="chat-md__code">${md.utils.escapeHtml(
content,
)}</code></pre></details>\n`;
}
// The SPARQL translation of the spec (task 10 subtask 4). Rendered first as
// a marker div after the details; renderMarkdown() then moves it inside the
// preceding details block, so spec and queries stay in one disclosure.
function queriesBlock(content) {
return `<div class="${QUERIES_CLASS}"><p class="chat-md__queries-label">${QUERIES_LABEL}</p>` +
`<pre><code class="chat-md__code">${md.utils.escapeHtml(
content,
)}</code></pre></div>\n`;
}
export function renderMarkdown(text) {
let html = md.render(String(text ?? ''));
// A ```sparql block that follows the spec disclosure joins it: the div is
// moved inside <details>, before its closing tag. html: false means the
// only details in the output are the ones this renderer emits, so the
// match is unambiguous.
html = html.replace(
/<\/details>\s*(<div class="chat-md__spec-queries">[\s\S]*?<\/div>)/,
'$1</details>\n',
);
return DOMPurify.sanitize(html);
}

View File

@@ -0,0 +1,13 @@
// The type is the ember-data model name, which is the SIOC class, which is
// rdf:type. No field exists whose only job is to say which component to draw.
// The assistant's messages are markdown; the user's own stay plain.
import InstantMessage from 'frontend-lblod-chat/components/chat/types/instant-message';
import AssistantMessage from 'frontend-lblod-chat/components/chat/types/assistant-message';
const TYPES = {
'chat-instant-message': InstantMessage,
};
export function componentFor(modelName, isAssistant) {
return isAssistant ? AssistantMessage : (TYPES[modelName] ?? InstantMessage);
}

7
app/utils/setting.js Normal file
View File

@@ -0,0 +1,7 @@
// A {{PLACEHOLDER}} that static-file-service did not fill in still starts
// with "{{". In development nothing fills them in.
export function setting(value, fallback) {
return typeof value === 'string' && value.trim() && !value.startsWith('{{')
? value
: fallback;
}

46
babel.config.mjs Normal file
View File

@@ -0,0 +1,46 @@
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
babelCompatSupport,
templateCompatSupport,
} from '@embroider/compat/babel';
export default {
plugins: [
[
'babel-plugin-ember-template-compilation',
{
enableLegacyModules: [
'ember-cli-htmlbars',
'ember-cli-htmlbars-inline-precompile',
'htmlbars-inline-precompile',
],
transforms: [...templateCompatSupport()],
},
],
[
'module:decorator-transforms',
{
runtime: {
import: fileURLToPath(
import.meta.resolve('decorator-transforms/runtime-esm'),
),
},
},
],
[
'@babel/plugin-transform-runtime',
{
absoluteRuntime: dirname(fileURLToPath(import.meta.url)),
useESModules: true,
regenerator: false,
},
],
'ember-concurrency/async-arrow-task-transform',
...babelCompatSupport(),
],
generatorOpts: {
compact: false,
},
};

60
config/environment.js Normal file
View File

@@ -0,0 +1,60 @@
'use strict';
module.exports = function (environment) {
const ENV = {
modulePrefix: 'frontend-lblod-chat',
environment,
rootURL: '/',
locationType: 'history',
EmberENV: {
EXTEND_PROTOTYPES: false,
FEATURES: {
// Here you can enable experimental features on an ember canary build
// e.g. EMBER_NATIVE_DECORATOR_SUPPORT: true
},
},
APP: {
// Here you can pass flags/options to your application instance
// when it is created
},
// {{PLACEHOLDER}} strings, filled in at container start by the
// static-file-service from EMBER_* variables. Unfilled ones still start
// with "{{"; app/utils/setting.js falls back to the default then.
appName: '{{APP_NAME}}',
assistantPath: '{{ASSISTANT_PATH}}',
acmidm: {
clientId: '{{ACMIDM_CLIENT_ID}}',
baseUrl: '{{ACMIDM_BASE_URL}}',
scope: 'openid vo profile abb_loketLB',
redirectUrl: '{{ACMIDM_REDIRECT_URL}}',
logoutUrl: '{{ACMIDM_LOGOUT_URL}}',
},
};
if (environment === 'development') {
// ENV.APP.LOG_RESOLVER = true;
// ENV.APP.LOG_ACTIVE_GENERATION = true;
// ENV.APP.LOG_TRANSITIONS = true;
// ENV.APP.LOG_TRANSITIONS_INTERNAL = true;
// ENV.APP.LOG_VIEW_LOOKUPS = true;
}
if (environment === 'test') {
// Testem prefers this...
ENV.locationType = 'none';
// keep test console output quieter
ENV.APP.LOG_ACTIVE_GENERATION = false;
ENV.APP.LOG_VIEW_LOOKUPS = false;
ENV.APP.rootElement = '#ember-testing';
ENV.APP.autoboot = false;
}
if (environment === 'production') {
// here you can do production-specific overrides
}
return ENV;
};

26
ember-cli-build.mjs Normal file
View File

@@ -0,0 +1,26 @@
// import { dirname } from 'node:path';
// import { fileURLToPath } from 'node:url';
import EmberApp from 'ember-cli/lib/broccoli/ember-app.js';
import { compatBuild } from '@embroider/compat';
export default async function (defaults) {
// const { setConfig } = await import('@warp-drive/core/build-config');
const { buildOnce } = await import('@embroider/vite');
const app = new EmberApp(defaults, {
// Add options here
});
// TODO: uncomment once we have started resolving WarpDrive deprecations
// setConfig(app, dirname(fileURLToPath(import.meta.url)), {
// // this should be the most recent <major>.<minor> version for
// // which all deprecations have been fully resolved
// // and should be updated when that changes
// compatWith: '5.8',
// deprecations: {
// // ... list individual deprecations that have been resolved here
// },
// });
return compatBuild(app, buildOnce);
}

110
eslint.config.mjs Normal file
View File

@@ -0,0 +1,110 @@
/**
* Debugging:
* https://eslint.org/docs/latest/use/configure/debug
* ----------------------------------------------------
*
* Print a file's calculated configuration
*
* npx eslint --print-config path/to/file.js
*
* Inspecting the config
*
* npx eslint --inspect-config
*
*/
import globals from 'globals';
import js from '@eslint/js';
import { defineConfig, globalIgnores } from 'eslint/config';
import ember from 'eslint-plugin-ember/recommended';
import WarpDrive from 'eslint-plugin-warp-drive/recommended';
import eslintConfigPrettier from 'eslint-config-prettier';
import qunit from 'eslint-plugin-qunit';
import n from 'eslint-plugin-n';
import babelParser from '@babel/eslint-parser/experimental-worker';
const esmParserOptions = {
ecmaFeatures: { modules: true },
ecmaVersion: 'latest',
};
export default defineConfig([
globalIgnores(['dist/', 'coverage/', '!**/.*']),
js.configs.recommended,
eslintConfigPrettier,
ember.configs.base,
ember.configs.gjs,
...WarpDrive,
/**
* https://eslint.org/docs/latest/use/configure/configuration-files#configuring-linter-options
*/
{
linterOptions: {
reportUnusedDisableDirectives: 'error',
},
},
{
files: ['**/*.js'],
languageOptions: {
parser: babelParser,
},
},
{
files: ['**/*.{js,gjs}'],
languageOptions: {
parserOptions: esmParserOptions,
globals: {
...globals.browser,
},
},
rules: {
'warp-drive/no-external-request-patterns': 'off', // Not sure if we want to replace _all_ regular fetch calls..
'warp-drive/no-legacy-request-patterns': 'off', // TODO: remove this once we refactored all the code to the new patterns
},
},
{
...qunit.configs.recommended,
files: ['tests/**/*-test.{js,gjs}'],
plugins: {
qunit,
},
},
/**
* CJS node files
*/
{
...n.configs['flat/recommended-script'],
files: ['**/*.cjs', 'config/**/*.js'],
plugins: {
n,
},
languageOptions: {
sourceType: 'script',
ecmaVersion: 'latest',
globals: {
...globals.node,
},
},
},
/**
* ESM node files
*/
{
...n.configs['flat/recommended-module'],
files: ['**/*.mjs'],
plugins: {
n,
},
languageOptions: {
sourceType: 'module',
ecmaVersion: 'latest',
parserOptions: esmParserOptions,
globals: {
...globals.node,
},
},
},
]);

30
index.html Normal file
View File

@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>Assistent</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{content-for "head"}}
<link integrity="" rel="stylesheet" href="/@embroider/virtual/vendor.css">
<link rel="icon" href="/assets/images/favicon.svg">
{{content-for "head-footer"}}
</head>
<body>
{{content-for "body"}}
<script src="/@embroider/virtual/vendor.js"></script>
<script type="module">
import Application from './app/app';
import environment from './app/config/environment';
Application.create(environment.APP);
</script>
{{content-for "body-footer"}}
</body>
</html>

6
jsconfig.json Normal file
View File

@@ -0,0 +1,6 @@
{
"compilerOptions": {
"emitDecoratorMetadata": true,
"experimentalDecorators": true
}
}

View File

@@ -0,0 +1,87 @@
import { loadEnv } from 'vite';
export default function apiProxy(options = {}) {
const { envKey = 'PROXY' } = options;
let { target } = options;
return {
name: 'vite-plugin-api-proxy',
config(config, { mode }) {
if (!target) {
const env = loadEnv(mode, process.cwd(), '');
target = env[envKey];
}
if (target) {
console.info(`[api-proxy] Proxy to ${target} enabled.`);
} else {
console.warn(
`[api-proxy] No target found for ${envKey}. Proxy disabled.`,
);
return;
}
return {
server: {
proxy: {
'^/.*': {
target,
changeOrigin: true,
secure: false,
bypass: (request) => {
return shouldProxy(request) ? null : request.url;
},
},
},
},
};
},
};
}
function shouldProxy(request) {
if (
request.headers.upgrade === 'websocket' &&
request.headers['sec-websocket-protocol'] === 'vite-hmr'
) {
return false;
}
// We assume all non-GET requests are API requests, which need to be proxied
if (request.method !== 'GET') return true;
// Known local paths that match most Ember + Vite apps
const knownLocalPaths = [
/^\/(\?.*)?$/,
/^\/index\.html/,
/^\/@vite/,
/^\/vite/,
/\/@-?embroider/,
/^\/app\//,
/^\/assets/,
/^\/node_modules\//,
/^\/vendor\//,
];
if (knownLocalPaths.some((path) => path.test(request.url))) {
return false;
}
const isLocalAsset =
/\.(js|ts|hbs|css|less|sass|scss|png|jpg|jpeg|gif|svg|ico|woff|woff2|json|map|webmanifest)$/i.test(
request.url,
);
if (isLocalAsset) {
return false;
}
const accept = request.headers.accept || '';
if (accept.includes('text/html')) {
return false;
}
// Everything else is assumed to be an API call
return true;
}

24604
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

110
package.json Normal file
View File

@@ -0,0 +1,110 @@
{
"name": "frontend-lblod-chat",
"version": "0.1.0",
"private": true,
"description": "Chat frontend for the natural language report assistant",
"repository": {
"type": "git",
"url": "git+https://github.com/lblod/frontend-lblod-chat.git"
},
"license": "MIT",
"author": "info@redpencil.io",
"exports": {
"./tests/*": "./tests/*",
"./*": "./app/*"
},
"directories": {
"doc": "doc",
"test": "tests"
},
"scripts": {
"build": "vite build",
"format": "prettier . --cache --write",
"lint": "concurrently \"npm:lint:*(!fix)\" --names \"lint:\" --prefixColors auto",
"lint:css": "stylelint \"**/*.css\" --allow-empty-input",
"lint:css:fix": "concurrently \"npm:lint:css -- --fix\"",
"lint:fix": "concurrently \"npm:lint:*:fix\" --names \"fix:\" --prefixColors auto && npm run format",
"lint:format": "prettier . --cache --check",
"lint:hbs": "ember-template-lint .",
"lint:hbs:fix": "ember-template-lint . --fix",
"lint:js": "eslint . --cache",
"lint:js:fix": "eslint . --fix",
"lint:deps": "ember dependency-lint",
"start": "vite",
"test": "vite build --mode development && ember test --path dist"
},
"devDependencies": {
"@appuniversum/ember-appuniversum": "^4.3.1",
"@babel/core": "^7.29.0",
"@babel/eslint-parser": "^7.28.6",
"@babel/plugin-transform-runtime": "^7.29.0",
"@babel/runtime": "^7.29.2",
"@ember/optional-features": "^3.0.0",
"@ember/render-modifiers": "^4.0.0",
"@ember/string": "^4.0.1",
"@ember/test-helpers": "^5.4.1",
"@ember/test-waiters": "^4.1.1",
"@embroider/compat": "^4.1.17",
"@embroider/config-meta-loader": "^1.0.0",
"@embroider/core": "^4.4.7",
"@embroider/legacy-inspector-support": "^0.1.3",
"@embroider/macros": "^1.20.2",
"@embroider/router": "^3.0.6",
"@embroider/vite": "^1.7.2",
"@eslint/js": "^9.39.4",
"@glimmer/component": "^2.0.0",
"@lblod/ember-acmidm-login": "^2.3.0",
"@lblod/ember-mock-login": "^0.14.0",
"@rollup/plugin-babel": "^6.1.0",
"@warp-drive/core": "~5.8.1",
"@warp-drive/ember": "~5.8.1",
"@warp-drive/json-api": "~5.8.1",
"@warp-drive/legacy": "~5.8.1",
"@warp-drive/utilities": "~5.8.1",
"babel-plugin-ember-template-compilation": "^3.1.0",
"concurrently": "^9.2.1",
"decorator-transforms": "^2.3.1",
"ember-cli": "~6.12.0",
"ember-cli-babel": "^8.3.1",
"ember-cli-dependency-lint": "^2.0.1",
"ember-cli-deprecation-workflow": "^3.4.0",
"ember-concurrency": "^5.2.0",
"ember-data": "~5.8.2",
"ember-load-initializers": "^3.0.1",
"ember-modifier": "^4.3.0",
"ember-page-title": "^9.0.3",
"ember-qunit": "^9.0.4",
"ember-resolver": "^13.2.0",
"ember-simple-auth": "^8.3.0",
"ember-source": "~6.12.0",
"ember-template-lint": "^7.9.3",
"ember-truth-helpers": "^5.0.0",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-ember": "^12.7.5",
"eslint-plugin-n": "^17.24.0",
"eslint-plugin-qunit": "^8.2.6",
"eslint-plugin-warp-drive": "^5.8.1",
"globals": "^16.5.0",
"prettier": "^3.8.1",
"prettier-plugin-ember-template-tag": "^2.1.3",
"qunit": "^2.25.0",
"qunit-dom": "^3.5.0",
"release-it": "^15.4.1",
"sass": "^1.48.0",
"stylelint": "^16.26.1",
"stylelint-config-standard": "^38.0.0",
"testem": "^3.19.1",
"vite": "^7.3.1"
},
"engines": {
"node": ">= 20.19.0"
},
"ember": {
"edition": "octane"
},
"dependencies": {
"dompurify": "^3.4.15",
"markdown-it": "^15.0.2"
}
}

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="180" viewBox="0 0 180 180">
<g fill="none" fill-rule="evenodd">
<polygon fill="#FFE500" points="0 .391 99.398 .391 161.529 180.391 0 180.391"/>
<path fill="#333332" fill-rule="nonzero" d="M99.3982111,89.0926718 C95.4094557,86.0641724 93.5258768,89.0926718 91.0144383,89.0926718 C88.5029997,89.0926718 86.8779512,85.3993798 85.2529028,86.3227028 C82.1136046,87.9108183 86.508622,93.7092868 88.4291339,94.7064756 C89.8424913,95.5089699 91.2972085,96.2363285 92.7872184,96.8855179 C95.0239484,97.9164265 96.4632281,100.14731 96.4805104,102.61012 C96.5963802,103.529808 96.5963802,104.460402 96.4805104,105.380089 C95.3355899,110.255235 86.7302196,114.687185 81.7073425,111.32629 C79.0146086,109.708417 77.1352081,107.023559 76.5367337,103.939706 C75.2071486,97.5503104 70.5536007,92.8598296 69.1501497,86.5812332 C68.2268267,82.8879412 67.5620341,78.7514542 66.5648453,74.9473635 C65.5676565,71.1432727 64.5335347,66.9329199 63.6840776,63.2396279 C62.7505298,59.383155 61.5913026,55.5848362 60.2123831,51.8642886 C55.4480364,39.6394921 53.34286,40.5258822 53.34286,40.5258822 C53.34286,40.5258822 55.0417743,43.9237108 61.2834378,73.76551 C62.3235811,80.177615 63.7168401,86.5274234 65.4568577,92.7859638 C66.0477844,94.3371464 67.0819062,97.6611092 67.7466987,99.138426 C69.7410764,103.422645 75.1332827,109.922839 75.3179473,115.795173 C75.5026119,119.193001 75.908874,121.999903 75.9827399,124.548275 C76.1110379,126.29633 76.4078795,128.027906 76.86913,129.718884 C78.1617822,133.412176 88.4660668,144.824448 99.3982111,144.824448 L99.3982111,135.923614 C91.776862,135.756786 84.3601993,133.425469 78.0140505,129.201823 C78.1564559,127.059782 78.5913762,124.947312 79.3067027,122.923226 C80.5375765,118.975189 84.0168987,116.15097 88.1336705,115.75824 C91.852584,115.82768 95.559014,116.210678 99.2135465,116.90316 L99.2135465,89.0926718 L99.3982111,89.0926718 Z M52.1610066,57.182629 C51.7547444,63.7197558 41.5612586,72.6944554 38.3850275,78.4190579 C36.5817907,81.7203936 35.2279073,85.2479431 34.3593392,88.9080072 C33.2582474,94.7541647 34.0608193,100.799344 36.6491802,106.155681 C40.0100759,114.207057 36.2059852,117.235557 38.8282225,115.536642 C42.0783194,112.877472 41.5981915,106.709675 41.339661,102.868651 C41.0074814,99.4049023 41.0074814,95.917316 41.339661,92.4535675 C42.8078022,85.312206 45.8483886,78.5875897 50.2404947,72.7683212 C53.2505575,68.1474786 53.9592745,62.3959674 52.1610066,57.182629 M54.2292501,75.649089 C54.2292501,75.649089 55.3003048,80.8196977 50.5359581,94.1155489 C38.4958262,129.017158 61.6158341,132.304188 68.0421621,140.503296 C68.0421621,140.503296 70.5905336,136.810004 60.1385173,125.730128 C56.4452253,121.593641 52.7519333,112.434277 55.8912315,99.3230906 C60.2123831,80.3765027 54.0445855,75.7229548 54.0445855,75.7229548 M31.4416385,52.8984103 C30.8740347,51.1083773 30.6118401,49.2355589 30.6660472,47.3584723 C31.4047056,39.1593641 38.9020883,35.8354013 40.7487343,34.616615 C42.4593349,33.5033039 43.7871245,31.8919087 44.5528251,30 C46.5589468,35.0036575 44.6726094,40.7250279 40.0839418,43.5543816 C36.359584,45.7670344 33.3574004,49.0129851 31.4416385,52.8984103 M50.3143606,44.9578325 C50.7206227,45.8072897 54.0076526,50.3131059 41.1549964,60.7281893 C28.3023403,71.1432727 32.4388274,78.6037225 32.4388274,78.6037225 C32.4388274,78.6037225 18.9213787,71.2171386 34.5070709,58.1798179 C50.0927631,45.1424971 46.879599,42.6679915 46.879599,42.6679915 C48.3780167,42.7144807 49.7154261,43.6204677 50.3143606,44.9947655 M66.7864428,48.9835208 C68.9654851,49.2789842 70.4797348,56.0746414 75.465679,57.5519582 C79.158971,58.6230129 83.1477263,58.0320862 84.0341164,60.2849943 C83.387359,60.8172796 83.1601821,61.7073389 83.4727257,62.4844743 C83.7852692,63.2616096 84.5654323,63.746543 85.4006344,63.6828229 C86.8410183,59.5463359 87.5427438,44.3299729 66.8233758,48.9835208 L66.7864428,48.9835208 Z M73.6559659,52.1597519 C73.6559659,51.679624 74.099161,52.1597519 74.6531548,51.7165569 C75.2325109,50.9649497 75.9784364,50.3580951 76.832197,49.9437767 C77.5762017,49.7011844 78.3780334,49.7011844 79.1220381,49.9437767 C79.5652331,49.9437767 79.1220381,51.1625631 79.1220381,51.4210935 C79.1220381,51.679624 76.389002,51.0886972 76.389002,52.602947 C76.389002,55.0774526 79.7498977,52.602947 81.4857449,52.602947 C82.3352021,58.6599458 72.3263808,56.9979644 73.8036976,52.0120202 L73.8036976,52.0120202 L73.6559659,52.1597519 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

25
testem.cjs Normal file
View File

@@ -0,0 +1,25 @@
'use strict';
if (typeof module !== 'undefined') {
module.exports = {
test_page: 'tests/index.html?hidepassed',
disable_watching: true,
launch_in_ci: ['Chrome'],
launch_in_dev: ['Chrome'],
browser_start_timeout: 120,
browser_args: {
Chrome: {
ci: [
// --no-sandbox is needed when running Chrome inside a container
process.env.CI ? '--no-sandbox' : null,
'--headless',
'--disable-dev-shm-usage',
'--disable-software-rasterizer',
'--mute-audio',
'--remote-debugging-port=0',
'--window-size=1440,900',
].filter(Boolean),
},
},
};
}

42
tests/helpers/index.js Normal file
View File

@@ -0,0 +1,42 @@
import {
setupApplicationTest as upstreamSetupApplicationTest,
setupRenderingTest as upstreamSetupRenderingTest,
setupTest as upstreamSetupTest,
} from 'ember-qunit';
// This file exists to provide wrappers around ember-qunit's
// test setup functions. This way, you can easily extend the setup that is
// needed per test type.
function setupApplicationTest(hooks, options) {
upstreamSetupApplicationTest(hooks, options);
// Additional setup for application tests can be done here.
//
// For example, if you need an authenticated session for each
// application test, you could do:
//
// hooks.beforeEach(async function () {
// await authenticateSession(); // ember-simple-auth
// });
//
// This is also a good place to call test setup functions coming
// from other addons:
//
// setupIntl(hooks, 'en-us'); // ember-intl
// setupMirage(hooks); // ember-cli-mirage
}
function setupRenderingTest(hooks, options) {
upstreamSetupRenderingTest(hooks, options);
// Additional setup for rendering tests can be done here.
}
function setupTest(hooks, options) {
upstreamSetupTest(hooks, options);
// Additional setup for unit tests can be done here.
}
export { setupApplicationTest, setupRenderingTest, setupTest };

43
tests/index.html Normal file
View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Frontend Worship Decisions Tests</title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{content-for "head"}}
{{content-for "test-head"}}
<link rel="stylesheet" href="/@embroider/virtual/vendor.css">
<link rel="stylesheet" href="/@embroider/virtual/app.css">
<link rel="stylesheet" href="/@embroider/virtual/test-support.css">
{{content-for "head-footer"}}
{{content-for "test-head-footer"}}
</head>
<body>
{{content-for "body"}}
{{content-for "test-body"}}
<div id="qunit"></div>
<div id="qunit-fixture">
<div id="ember-testing-container">
<div id="ember-testing"></div>
</div>
</div>
<script src="/testem.js" integrity="" data-embroider-ignore></script>
<script src="/@embroider/virtual/vendor.js"></script>
<script src="/@embroider/virtual/test-support.js"></script>
<script type="module">import "ember-testing";</script>
<script type="module">
import { start } from './test-helper';
import.meta.glob("./**/*.{js,ts,gjs,gts}", { eager: true });
start();
</script>
{{content-for "body-footer"}}
</body>
</html>

16
tests/test-helper.js Normal file
View File

@@ -0,0 +1,16 @@
import '@warp-drive/ember/install';
import Application from 'frontend-lblod-chat/app';
import config from 'frontend-lblod-chat/config/environment';
import * as QUnit from 'qunit';
import { setApplication } from '@ember/test-helpers';
import { setup } from 'qunit-dom';
import { start as qunitStart, setupEmberOnerrorValidation } from 'ember-qunit';
export function start() {
setApplication(Application.create(config.APP));
setup(QUnit.assert);
setupEmberOnerrorValidation();
qunitStart();
}

17
vite.config.mjs Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import { extensions, classicEmberSupport, ember } from '@embroider/vite';
import { babel } from '@rollup/plugin-babel';
import apiProxy from './lib/vite-plugin-api-proxy.mjs';
export default defineConfig({
plugins: [
classicEmberSupport(),
ember(),
// extra plugins here
apiProxy(),
babel({
babelHelpers: 'runtime',
extensions,
}),
],
});