summaryrefslogtreecommitdiff
path: root/ping/frontend/src/lib
diff options
context:
space:
mode:
authorMartial Simon <msimon_fr@hotmail.com>2025-09-15 01:07:58 +0200
committerMartial Simon <msimon_fr@hotmail.com>2025-09-15 01:07:58 +0200
commit967be9e750221ab2ab783f95df79bb26d290a45e (patch)
tree6802900a5e975f9f68b169f0f503f040056d6952 /ping/frontend/src/lib
add: added projectsHEADmain
Diffstat (limited to 'ping/frontend/src/lib')
-rw-r--r--ping/frontend/src/lib/components/Avatar.svelte48
-rw-r--r--ping/frontend/src/lib/components/Button.svelte17
-rw-r--r--ping/frontend/src/lib/components/NavBar.svelte44
-rw-r--r--ping/frontend/src/lib/components/NumberStatList.svelte55
-rw-r--r--ping/frontend/src/lib/components/SideBar.svelte61
-rw-r--r--ping/frontend/src/lib/components/SteppedLineChart.svelte76
-rw-r--r--ping/frontend/src/lib/components/ToastList.svelte67
-rw-r--r--ping/frontend/src/lib/components/UserItem.svelte42
-rw-r--r--ping/frontend/src/lib/components/dashboard/RiskAnalysis.svelte10
-rw-r--r--ping/frontend/src/lib/components/dashboard/StockGraph.svelte136
-rw-r--r--ping/frontend/src/lib/components/dashboard/TrendingSymbols.svelte85
-rw-r--r--ping/frontend/src/lib/components/dashboard/transactions/TransactionModal.svelte165
-rw-r--r--ping/frontend/src/lib/components/input/StockSelector.svelte231
-rw-r--r--ping/frontend/src/lib/components/input/UserSelector.svelte35
-rw-r--r--ping/frontend/src/lib/pages.ts54
-rw-r--r--ping/frontend/src/lib/stores/auth.ts66
-rw-r--r--ping/frontend/src/lib/stores/toast.ts22
17 files changed, 1214 insertions, 0 deletions
diff --git a/ping/frontend/src/lib/components/Avatar.svelte b/ping/frontend/src/lib/components/Avatar.svelte
new file mode 100644
index 0000000..c04b563
--- /dev/null
+++ b/ping/frontend/src/lib/components/Avatar.svelte
@@ -0,0 +1,48 @@
+<script lang="ts">
+ let showTooltip = $state(false);
+
+ interface Props {
+ username?: string;
+ url?: string;
+ onclick?: () => void;
+ }
+
+ let { username = 'lolo', url = '/img/default-avatar.png', onclick = () => {} }: Props = $props();
+</script>
+
+<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
+<div style="position: relative; display: inline-block;">
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
+ <img
+ src={url ?? '/img/default-avatar.png'}
+ onmouseenter={() => (showTooltip = true)}
+ onmouseleave={() => (showTooltip = false)}
+ {onclick}
+ alt="User Avatar"
+ />
+ {#if showTooltip}
+ <div class="tooltip">{username}</div>
+ {/if}
+</div>
+
+<style>
+ img {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ }
+ .tooltip {
+ position: absolute;
+ top: 110%;
+ left: 50%;
+ transform: translateX(-50%);
+ background: #333;
+ color: #fff;
+ padding: 4px 8px;
+ border-radius: 4px;
+ white-space: nowrap;
+ font-size: 0.9em;
+ z-index: 1;
+ pointer-events: none;
+ }
+</style>
diff --git a/ping/frontend/src/lib/components/Button.svelte b/ping/frontend/src/lib/components/Button.svelte
new file mode 100644
index 0000000..54b26ab
--- /dev/null
+++ b/ping/frontend/src/lib/components/Button.svelte
@@ -0,0 +1,17 @@
+<script lang="ts">
+ // Accept props and ensure onclick matches the expected event handler signature
+ const {
+ href = undefined as string | undefined,
+ onclick = undefined as
+ | ((event: MouseEvent & { currentTarget: EventTarget & HTMLButtonElement }) => void)
+ | undefined,
+ disabled = false,
+ children
+ } = $props();
+</script>
+
+{#if href}
+ <a class="btn" {href}>{@render children?.()}</a>
+{:else}
+ <button class="btn" {onclick} {disabled}>{@render children?.()}</button>
+{/if}
diff --git a/ping/frontend/src/lib/components/NavBar.svelte b/ping/frontend/src/lib/components/NavBar.svelte
new file mode 100644
index 0000000..1a76876
--- /dev/null
+++ b/ping/frontend/src/lib/components/NavBar.svelte
@@ -0,0 +1,44 @@
+<script lang="ts">
+ const { pageTitle, rightComponent } = $props();
+</script>
+
+<nav>
+ <a id="logo" href="/">
+ <img src="/img/logo.svg" alt="" style="height:50px" />
+ </a>
+ <div id="middletitle">{pageTitle}</div>
+ <div id="right">{@render rightComponent()}</div>
+</nav>
+
+<style>
+ nav {
+ position: fixed;
+ width: 100%;
+ background-color: var(--bg-primary);
+ color: white;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px;
+ height: 64px;
+ z-index: 2;
+ }
+
+ #logo {
+ transition-duration: 0.2s;
+ }
+
+ #logo:hover {
+ transform: scale(1.025);
+ filter: saturate(2);
+ }
+
+ #middletitle {
+ font-weight: bold;
+ font-size: 24px;
+ }
+
+ #right {
+ display: flex;
+ }
+</style>
diff --git a/ping/frontend/src/lib/components/NumberStatList.svelte b/ping/frontend/src/lib/components/NumberStatList.svelte
new file mode 100644
index 0000000..b6444e8
--- /dev/null
+++ b/ping/frontend/src/lib/components/NumberStatList.svelte
@@ -0,0 +1,55 @@
+<script lang="ts">
+ export interface INumberStatList {
+ name: string;
+ value: string;
+ icon: string;
+ color: string;
+ }
+
+ const { statsList = [] }: { statsList?: INumberStatList[] } = $props();
+</script>
+
+<section class="numberStatList">
+ {#each statsList as stat}
+ <div class="card">
+ <img src={stat.icon} alt="" class="stat-icon" />
+ <span class="stat-name">{stat.name}</span>
+ <span class="stat-value" style:color={stat.color}>{stat.value}</span>
+ </div>
+ {/each}
+</section>
+
+<style>
+ .numberStatList {
+ color: white;
+ display: flex;
+ width: 100%;
+ align-items: stretch;
+ justify-content: stretch;
+ }
+
+ .numberStatList :global(.card) {
+ flex: 1;
+ display: grid;
+ grid-template-areas: 'icon name' 'icon amount';
+ align-items: center;
+ justify-content: space-around;
+ }
+
+ .stat-icon {
+ width: 24px;
+ height: 24px;
+ grid-area: icon;
+ }
+
+ .stat-name {
+ grid-area: name;
+ font-weight: bold;
+ }
+
+ .stat-value {
+ grid-area: amount;
+ font-size: 1.2em;
+ font-weight: bold;
+ }
+</style>
diff --git a/ping/frontend/src/lib/components/SideBar.svelte b/ping/frontend/src/lib/components/SideBar.svelte
new file mode 100644
index 0000000..9ea8a04
--- /dev/null
+++ b/ping/frontend/src/lib/components/SideBar.svelte
@@ -0,0 +1,61 @@
+<script lang="ts">
+ import { pages } from '$lib/pages';
+
+ const {
+ selectedIndex = $bindable(0)
+ }: {
+ selectedIndex: number;
+ } = $props();
+
+ const items = pages;
+</script>
+
+<section id="sidebar">
+ {#each items as item, index}
+ <!-- svelte-ignore a11y_consider_explicit_label -->
+ <a href={item.href} class:selected={index == selectedIndex} class="flex items-center gap-2">
+ <img
+ src={item.icon}
+ alt=" "
+ class="w-6"
+ style="filter: {index == selectedIndex
+ ? 'invert(68%) sepia(97%) saturate(749%) hue-rotate(97deg) brightness(154%) contrast(101%)'
+ : 'none'};"
+ />
+ <span>{item.name}</span>
+ </a>
+ {/each}
+</section>
+
+<style>
+ #sidebar {
+ position: fixed;
+ background-color: var(--bg-primary);
+ height: 100vh;
+ width: 200px;
+ padding-top: 72px;
+ color: white;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ #sidebar > a {
+ padding: 8px;
+ padding-right: 0;
+ transition-duration: 0.2s;
+ border-color: var(--text-lime);
+ }
+
+ #sidebar > a:is(:hover, :focus) {
+ background-color: var(--bg-secondary);
+ }
+
+ .selected {
+ color: var(--text-lime);
+ background-color: var(--bg-secondary);
+ font-weight: bold;
+ border-right: 6px var(--text-lime) solid;
+ width: 100%;
+ }
+</style>
diff --git a/ping/frontend/src/lib/components/SteppedLineChart.svelte b/ping/frontend/src/lib/components/SteppedLineChart.svelte
new file mode 100644
index 0000000..d393b44
--- /dev/null
+++ b/ping/frontend/src/lib/components/SteppedLineChart.svelte
@@ -0,0 +1,76 @@
+<script lang="ts">
+ import {
+ Chart,
+ CategoryScale,
+ LinearScale,
+ PointElement,
+ LineElement,
+ Title,
+ Tooltip,
+ Legend,
+ LineController
+ } from 'chart.js';
+ import { onMount } from 'svelte';
+
+ Chart.register(
+ CategoryScale,
+ LinearScale,
+ PointElement,
+ LineElement,
+ Title,
+ Tooltip,
+ Legend,
+ LineController
+ );
+
+ interface IProps {
+ color?: string;
+ data?: any[];
+ title?: string;
+ legend?: string;
+ }
+
+ const { color = 'green', data = [], title = '', legend = '' }: IProps = $props();
+
+ function updateGraph() {
+ const ctx = document.getElementById(id) as HTMLCanvasElement;
+ const chart = new Chart(ctx, {
+ type: 'line',
+ data: {
+ labels: data.map((d) => d.label),
+ datasets: [
+ {
+ label: legend,
+ data: data.map((d) => d.value),
+ borderColor: color,
+ backgroundColor: color,
+ fill: false,
+ tension: 0.1
+ }
+ ]
+ },
+ options: {
+ responsive: true,
+ plugins: {
+ title: {
+ display: true,
+ text: title
+ },
+ legend: {
+ display: true
+ }
+ }
+ }
+ });
+ }
+
+ const id = 'graph-' + crypto.randomUUID();
+
+ onMount(() => {
+ updateGraph();
+ });
+</script>
+
+<div>
+ <canvas {id}></canvas>
+</div>
diff --git a/ping/frontend/src/lib/components/ToastList.svelte b/ping/frontend/src/lib/components/ToastList.svelte
new file mode 100644
index 0000000..5351ef7
--- /dev/null
+++ b/ping/frontend/src/lib/components/ToastList.svelte
@@ -0,0 +1,67 @@
+<script lang="ts">
+ import { toastList } from '$lib/stores/toast';
+</script>
+
+<div class="toast">
+ {#each $toastList as toast}
+ <div class="toast-item" style="border-top: 4px {toast.color} solid">
+ <h3 style="color: {toast.color}">{toast.title}</h3>
+ <p>{toast.message}</p>
+ </div>
+ {/each}
+</div>
+
+<style>
+ .toast {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ z-index: 1000;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ }
+
+ .toast-item {
+ background-color: var(--bg-secondary);
+ padding: 10px;
+ color: white;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+ transition:
+ transform 0.3s,
+ opacity 0.3s;
+ transform: translateY(30px);
+ opacity: 0;
+ animation-delay: 0s, 4.7s;
+ animation-name: toast-in, toast-out;
+ animation-duration: 0.3s, 0.3s;
+ animation-fill-mode: forwards, forwards;
+ }
+
+ @keyframes toast-in {
+ from {
+ transform: translateY(30px);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+ }
+
+ @keyframes toast-out {
+ from {
+ transform: translateY(0);
+ opacity: 1;
+ }
+ to {
+ transform: translateY(30px);
+ opacity: 0;
+ }
+ }
+
+ .toast-item h3 {
+ margin: 0;
+ font-weight: bold;
+ }
+</style>
diff --git a/ping/frontend/src/lib/components/UserItem.svelte b/ping/frontend/src/lib/components/UserItem.svelte
new file mode 100644
index 0000000..44add60
--- /dev/null
+++ b/ping/frontend/src/lib/components/UserItem.svelte
@@ -0,0 +1,42 @@
+<script lang="ts">
+ import Avatar from './Avatar.svelte';
+ import type { IUser } from '$lib/stores/auth';
+
+ const { user, onEdit, onDelete }: { user: IUser; onEdit: any; onDelete: any } = $props();
+</script>
+
+<div class="useritem">
+ <div class="useritem-avatar">
+ <Avatar username={user.displayName} url={user.avatar || '/img/default-avatar.png'} />
+ <button class="btn" onclick={onEdit}> Modifier </button>
+ <button class="btn" onclick={onDelete}> Supprimer </button>
+ </div>
+ <div class="useritem-details">
+ <p><b>Nom</b>: {user.displayName}</p>
+ <p><b>Login</b>: {user.login}</p>
+ <p><b>Role</b>: {user.isAdmin ? 'ADMIN' : 'USER'}</p>
+ <p><b>Id</b>: {user.id}</p>
+ </div>
+</div>
+
+<style>
+ .useritem {
+ display: flex;
+ background-color: var(--bg-secondary);
+ margin: 16px 0;
+ padding: 8px;
+ border-radius: 8px;
+ gap: 8px;
+ }
+
+ .useritem-avatar,
+ .useritem-details {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ .useritem-avatar {
+ align-items: center;
+ }
+</style>
diff --git a/ping/frontend/src/lib/components/dashboard/RiskAnalysis.svelte b/ping/frontend/src/lib/components/dashboard/RiskAnalysis.svelte
new file mode 100644
index 0000000..3844abc
--- /dev/null
+++ b/ping/frontend/src/lib/components/dashboard/RiskAnalysis.svelte
@@ -0,0 +1,10 @@
+<h2>Analyse de risque</h2>
+<i>tkt t safe c hardcodé chef</i>
+
+<style>
+ h2 {
+ font-weight: bold;
+ color: var(--text-lime);
+ font-size: 24px;
+ }
+</style>
diff --git a/ping/frontend/src/lib/components/dashboard/StockGraph.svelte b/ping/frontend/src/lib/components/dashboard/StockGraph.svelte
new file mode 100644
index 0000000..beefed9
--- /dev/null
+++ b/ping/frontend/src/lib/components/dashboard/StockGraph.svelte
@@ -0,0 +1,136 @@
+<script lang="ts">
+ import Chart from 'chart.js/auto';
+ import { onMount } from 'svelte';
+ import StockSelector from '../input/StockSelector.svelte';
+
+ async function fetchChartData(stock: string, startDate: string, endDate: string) {
+ const res = await fetch(
+ `/stocksapi/chart?query=${stock}&startDate=${startDate}&endDate=${endDate}&interval=${range}`,
+ {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'no-cache'
+ }
+ }
+ );
+ return await res.json();
+ }
+
+ function updateGraph() {
+ fetchChartData(selectedStock, startDate, endDate).then((data) => {
+ if (chart) {
+ chart.destroy();
+ chart = null;
+ }
+
+ const { quotes, meta } = data;
+
+ validRanges = meta.validRanges;
+
+ const labels = quotes.map((item: any) => item.date.split('T')[0]);
+ const datasets = ['low', 'high', 'open', 'close'].map((key) => ({
+ label: key.charAt(0).toUpperCase() + key.slice(1),
+ data: quotes.map((item: any) => item[key]),
+ borderColor:
+ key === 'low'
+ ? 'rgb(255, 99, 132)'
+ : key === 'high'
+ ? 'rgb(54, 162, 235)'
+ : key === 'open'
+ ? 'rgb(255, 205, 86)'
+ : 'rgb(75, 192, 192)',
+ fill: false
+ }));
+
+ // @ts-ignore
+ chart = new Chart(document.getElementById('stockgraph'), {
+ type: 'line',
+ data: {
+ labels,
+ datasets
+ },
+ options: {
+ responsive: true,
+ maintainAspectRatio: true,
+ plugins: {
+ title: {
+ display: true,
+ text: meta.longName
+ }
+ }
+ }
+ });
+ });
+ }
+
+ let today = $state(new Date().toISOString().split('T')[0]);
+
+ let selectedStock = $state('2223.SR');
+ let startDate = $state('');
+ let endDate = $state('');
+ let chart = $state<Chart | null>(null);
+ let range = $state('1d');
+ let validRanges = $state(['1d']);
+
+ onMount(() => {
+ endDate = new Date().toISOString().split('T')[0];
+ startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
+
+ updateGraph();
+ });
+
+ $effect(() => {
+ if (selectedStock && startDate && endDate && range) {
+ updateGraph();
+ }
+ });
+
+ $effect(() => {
+ if (startDate && endDate && startDate > endDate) {
+ let temp = startDate;
+ startDate = endDate;
+ endDate = temp;
+ }
+ });
+</script>
+
+<div>
+ <div class="header">
+ <h2>Vue d'ensemble : {selectedStock}</h2>
+ <div class="controls">
+ <input type="date" name="startDate" id="startDate" bind:value={startDate} max={today} />
+ <input type="date" name="endDate" id="endDate" bind:value={endDate} max={today} />
+ <select name="validRanges" id="validRanges" bind:value={range}>
+ {#each validRanges as r}
+ <option value={r}>
+ {r}
+ </option>
+ {/each}
+ </select>
+
+ <StockSelector bind:selectedStock />
+ </div>
+ </div>
+ <canvas id="stockgraph"></canvas>
+</div>
+
+<style>
+ .header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ max-height: 512px !important;
+ }
+
+ h2 {
+ font-weight: bold;
+ color: var(--text-lime);
+ font-size: 24px;
+ }
+
+ #stockgraph {
+ width: 100% !important;
+ max-height: 512px !important;
+ }
+</style>
diff --git a/ping/frontend/src/lib/components/dashboard/TrendingSymbols.svelte b/ping/frontend/src/lib/components/dashboard/TrendingSymbols.svelte
new file mode 100644
index 0000000..3d947ad
--- /dev/null
+++ b/ping/frontend/src/lib/components/dashboard/TrendingSymbols.svelte
@@ -0,0 +1,85 @@
+<script lang="ts">
+ import { Chart } from 'chart.js';
+ import { onMount } from 'svelte';
+
+ function updateGraph() {
+ getTrendingSymbols().then((trendingSymbols) => {
+ const labels = trendingSymbols.map((d) => d.longName);
+
+ const datasets = [
+ {
+ label: '50 day performance',
+ data: trendingSymbols.map((ts) => ts.fiftyDayAverageChange),
+ backgroundColor: 'green'
+ },
+ {
+ label: '52 week performance',
+ data: trendingSymbols.map((ts) => ts.fiftyTwoWeekLowChange),
+ backgroundColor: 'orange'
+ }
+ ];
+
+ const data = { labels, datasets };
+
+ // @ts-ignore
+ new Chart(document.getElementById('graph'), {
+ type: 'bar',
+ data: data,
+ options: {
+ indexAxis: 'y',
+ elements: {
+ bar: {
+ borderWidth: 2
+ }
+ },
+ responsive: true,
+ plugins: {
+ legend: {
+ position: 'bottom'
+ }
+ }
+ }
+ });
+ });
+ }
+
+ async function getTrendingSymbols() {
+ const trendingRes = await fetch('/stocksapi/trendingSymbols');
+ if (!trendingRes.ok) {
+ throw new Error('Failed to fetch trending symbols');
+ }
+ const trendingJson = await trendingRes.json();
+
+ const { quotes } = trendingJson;
+
+ const trendingSymbols = await Promise.all(
+ quotes.map(async (quote: any) => {
+ const quoteRes = await fetch(`/stocksapi/quote?query=${quote.symbol}`);
+ const quoteJson = await quoteRes.json();
+ return quoteJson;
+ })
+ );
+
+ return trendingSymbols;
+ }
+
+ onMount(() => {
+ updateGraph();
+ });
+</script>
+
+<h2>Tendances</h2>
+<canvas id="graph"></canvas>
+
+<style>
+ h2 {
+ font-weight: bold;
+ color: var(--text-lime);
+ font-size: 24px;
+ }
+
+ #graph {
+ max-height: 40vh;
+ max-width: 40vw;
+ }
+</style>
diff --git a/ping/frontend/src/lib/components/dashboard/transactions/TransactionModal.svelte b/ping/frontend/src/lib/components/dashboard/transactions/TransactionModal.svelte
new file mode 100644
index 0000000..b9f0224
--- /dev/null
+++ b/ping/frontend/src/lib/components/dashboard/transactions/TransactionModal.svelte
@@ -0,0 +1,165 @@
+<script lang="ts">
+ import { authFetch } from '$lib/stores/auth';
+ import { onMount } from 'svelte';
+
+ interface IProps {
+ isOpen: boolean;
+ onCreate: (transaction: any) => void;
+ }
+
+ let { isOpen = $bindable(false), onCreate = () => {} }: IProps = $props(); // Changed default to false
+
+ let amount: number = $state(0);
+ let currency: string = $state('USD');
+ let label: string = $state('');
+ let receiverLabel: string = $state('');
+ let receiverIban: string = $state('');
+ let operationDate: string = $state('');
+
+ function closeDialog() {
+ isOpen = false;
+ }
+
+ async function createTransaction(event: Event) {
+ event.preventDefault();
+
+ const transaction = {
+ amount,
+ currency,
+ label,
+ receiverLabel,
+ receiverIban,
+ operationDate
+ };
+
+ try {
+ const response = await authFetch('/api/transactions', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(transaction)
+ });
+
+ if (!response.ok) {
+ const error = await response.json();
+ alert('Erreur lors de la création de la transaction: ' + error);
+ return;
+ }
+
+ onCreate(await response.json());
+
+ closeDialog();
+ } catch (err) {
+ alert('Erreur réseau lors de la création de la transaction');
+ }
+ }
+</script>
+
+<!-- svelte-ignore a11y_click_events_have_key_events -->
+<!-- svelte-ignore a11y_no_static_element_interactions -->
+{#if isOpen}
+ <div class="backdrop" onclick={closeDialog}>
+ <dialog open onclick={(e) => e.stopPropagation()}>
+ <h2>Créer une nouvelle transaction</h2>
+ <form class="flex flex-col gap-2">
+ <label>
+ <span>Montant</span>
+ <input type="number" step="0.01" min="0" bind:value={amount} required />
+ </label>
+ <label>
+ <span>Devise</span>
+ <select bind:value={currency} required>
+ <option selected value="USD" data-symbol="$" data-name="Dollar américain"
+ >USD - Dollar américain</option
+ >
+ <option value="EUR" data-symbol="€" data-name="Euro">EUR - Euro</option>
+ <option value="GBP" data-symbol="£" data-name="Livre sterling"
+ >GBP - Livre sterling</option
+ >
+ <option value="JPY" data-symbol="¥" data-name="Yen japonais">JPY - Yen japonais</option>
+ <option value="CHF" data-symbol="Fr" data-name="Franc suisse">CHF - Franc suisse</option
+ >
+ <option value="CAD" data-symbol="$" data-name="Dollar canadien"
+ >CAD - Dollar canadien</option
+ >
+ <option value="AUD" data-symbol="$" data-name="Dollar australien"
+ >AUD - Dollar australien</option
+ >
+ <option value="CNY" data-symbol="¥" data-name="Yuan chinois">CNY - Yuan chinois</option>
+ </select>
+ </label>
+ <label>
+ <span>Libellé</span>
+ <input type="text" bind:value={label} required />
+ </label>
+ <label>
+ <span>Libellé du bénéficiaire</span>
+ <input type="text" bind:value={receiverLabel} required />
+ </label>
+ <label>
+ <span>IBAN du bénéficiaire</span>
+ <input type="text" bind:value={receiverIban} required />
+ </label>
+ <label>
+ <span>Date de l'opération</span>
+ <input type="datetime-local" bind:value={operationDate} required />
+ </label>
+ <button class="btn" onclick={createTransaction}>➕ Créer</button>
+ </form>
+ </dialog>
+ </div>
+{/if}
+
+<style>
+ .backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ background: rgba(0, 0, 0, 0.4);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ }
+
+ dialog {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ max-width: 90vw;
+ max-height: 90vh;
+ border: none;
+ border-radius: 8px;
+ box-shadow: 0 2px 16px rgba(0, 0, 0, 0.2);
+ padding: 2rem;
+ background: var(--bg-primary);
+ outline: none;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ align-items: center;
+ justify-content: center;
+ color: white;
+ }
+
+ h2 {
+ font-weight: 600;
+ font-size: 32px;
+ color: var(--text-lime);
+ }
+
+ form {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ label {
+ display: flex;
+ flex-direction: column;
+ }
+</style>
diff --git a/ping/frontend/src/lib/components/input/StockSelector.svelte b/ping/frontend/src/lib/components/input/StockSelector.svelte
new file mode 100644
index 0000000..1237128
--- /dev/null
+++ b/ping/frontend/src/lib/components/input/StockSelector.svelte
@@ -0,0 +1,231 @@
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import Button from '../Button.svelte';
+
+ let { selectedStock = $bindable('AAPL') } = $props();
+
+ let stocks: {
+ symbol: string;
+ shortname: string;
+ quoteType: string;
+ isYahooFinance: boolean;
+ }[] = $state([]);
+ let news: {
+ link: string;
+ title: string;
+ thumbnail: { resolutions: { url: string; width: number; height: number }[] };
+ }[] = $state([]);
+
+ function openDialog() {
+ const dialog = document.querySelector('dialog');
+ const backdrop = document.getElementById('backdrop');
+ // @ts-ignore
+ dialog.open = true;
+ // @ts-ignore
+ backdrop.style.display = 'block';
+ }
+
+ function closeDialog() {
+ const dialog = document.querySelector('dialog');
+ const backdrop = document.getElementById('backdrop');
+ // @ts-ignore
+ dialog.open = false;
+ // @ts-ignore
+ backdrop.style.display = 'none';
+ }
+
+ function onSearch(event: SubmitEvent) {
+ event.preventDefault();
+ // @ts-ignore
+ const fd = new FormData(event.target);
+ const searchQuery = fd.get('search');
+
+ fetch(`/stocksapi/search?query=${searchQuery}`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'Cache-Control': 'no-cache'
+ }
+ })
+ .then((response) => response.json())
+ .then((data) => {
+ stocks = data.quotes.filter((q: any) => q.isYahooFinance);
+ news = data.news;
+ })
+ .catch((error) => {
+ console.error('Error fetching stock data:', error);
+ });
+ }
+
+ onMount(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ closeDialog();
+ }
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ });
+</script>
+
+<Button onclick={openDialog}>{selectedStock}</Button>
+
+<dialog>
+ <form onsubmit={onSearch}>
+ <input
+ type="search"
+ name="search"
+ id="search"
+ placeholder="Recherche d'actions, ETFs, entreprises"
+ value={selectedStock || ''}
+ />
+ <button type="submit" class="btn">Rechercher</button>
+ </form>
+ <div class="results">
+ <div class="stocks">
+ {#if stocks.length === 0}
+ <p>Aucune action, ETFs trouvés.</p>
+ {:else}
+ <p>{stocks.length} actions, ETFs trouvés</p>
+ {#each stocks as stock}
+ <button
+ class="btn stock"
+ onclick={() => {
+ selectedStock = stock.symbol;
+ closeDialog();
+ }}
+ disabled={!stock.isYahooFinance}
+ >
+ <pre>{stock.symbol}</pre>
+ <span>{stock.shortname}</span>
+ <i>{stock.quoteType}</i>
+ </button>
+ {/each}
+ {/if}
+ </div>
+ <div class="news">
+ {#if news.length === 0}
+ <p>Pas de news trouvés.</p>
+ {:else}
+ <p>{news.length} news trouvées</p>
+ {#each news as newsItem}
+ <a class="newsItem" href={newsItem.link}>
+ {#if newsItem.thumbnail?.resolutions?.length > 0}
+ <img
+ src={newsItem.thumbnail.resolutions[0].url}
+ alt="News Thumbnail"
+ width={newsItem.thumbnail.resolutions[0].width}
+ height={newsItem.thumbnail.resolutions[0].height}
+ />
+ {/if}
+ <h1>{newsItem.title}</h1></a
+ >
+ {/each}
+ {/if}
+ </div>
+ </div>
+</dialog>
+<!-- svelte-ignore a11y_click_events_have_key_events -->
+<!-- svelte-ignore a11y_no_static_element_interactions -->
+<div id="backdrop" onclick={closeDialog}></div>
+
+<style>
+ dialog {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 300px;
+ padding: 20px;
+ background-color: var(--bg-primary);
+ border: 4px solid #ccc;
+ border-radius: 16px;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+ height: 90vh;
+ width: 90vw;
+ z-index: 1000;
+ color: white;
+ }
+
+ #backdrop {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.5);
+ z-index: 999;
+ display: none;
+ }
+
+ form {
+ display: flex;
+ gap: 10px;
+ }
+
+ input[type='search'] {
+ width: 300px;
+ }
+
+ .results {
+ display: flex;
+ flex-direction: row;
+ gap: 20px;
+ justify-content: space-evenly;
+ }
+
+ .stocks,
+ .news {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ overflow-y: auto;
+ max-height: 70vh;
+ padding: 16px;
+ flex: 1;
+ }
+
+ .stock {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .stock pre {
+ font-weight: bold;
+ padding: 8px;
+ background: var(--bg-primary);
+ border-radius: 8px;
+ }
+
+ .stock span {
+ font-weight: bold;
+ color: var(--text-lime);
+ }
+
+ .newsItem {
+ display: flex;
+ flex-direction: column;
+ background-color: var(--bg-secondary);
+ padding-bottom: 4px;
+ border-radius: 8px;
+ transition-duration: 0.2s;
+ }
+
+ .newsItem h1 {
+ margin: 16px;
+ }
+
+ .newsItem img {
+ border-radius: 8px;
+ width: 100%;
+ height: auto;
+ }
+
+ .newsItem:hover {
+ transform: scale(1.025);
+ background-color: #333;
+ }
+</style>
diff --git a/ping/frontend/src/lib/components/input/UserSelector.svelte b/ping/frontend/src/lib/components/input/UserSelector.svelte
new file mode 100644
index 0000000..a0c80c7
--- /dev/null
+++ b/ping/frontend/src/lib/components/input/UserSelector.svelte
@@ -0,0 +1,35 @@
+<script lang="ts">
+ import type { IUser } from '$lib/stores/auth';
+ import { addToast } from '$lib/stores/toast';
+ import Avatar from '../Avatar.svelte';
+
+ let { users = $bindable<IUser[]>([]) }: { users: IUser[] } = $props();
+</script>
+
+<div>
+ {#each users as u, i}
+ <Avatar
+ username={u.displayName}
+ url={u.avatar}
+ onclick={() => {
+ users = users.filter((_, index) => index !== i);
+ }}
+ />
+ {/each}
+ <Avatar
+ username={'Ajouter un utilisateur'}
+ url={'/icons/add-green.svg'}
+ onclick={() => {
+ let userId = prompt("Entrez l'ID de l'utilisateur à ajouter :");
+ if (userId === null || userId.trim() === '') {
+ addToast({ title: 'ID utilisateur invalide.' });
+ return;
+ }
+ if (users.map((u) => u.displayName).includes(userId)) {
+ addToast({ title: 'Cet utilisateur est déjà ajouté.' });
+ return;
+ }
+ users = [...users, { displayName: userId, avatar: '/img/default-avatar.png' } as IUser];
+ }}
+ />
+</div>
diff --git a/ping/frontend/src/lib/pages.ts b/ping/frontend/src/lib/pages.ts
new file mode 100644
index 0000000..e5d424e
--- /dev/null
+++ b/ping/frontend/src/lib/pages.ts
@@ -0,0 +1,54 @@
+interface SideBarItem {
+ icon: string;
+ name: string;
+ href: string;
+}
+
+export const pages: SideBarItem[] = [
+ {
+ name: 'Dashboard',
+ icon: '/icons/dashboard.svg',
+ href: '/dashboard'
+ },
+ {
+ name: 'Transactions',
+ icon: '/icons/credit-card.svg',
+ href: '/dashboard/transactions'
+ },
+ {
+ name: 'Modèles',
+ icon: '/icons/floppy-disk.svg',
+ href: '/dashboard/models'
+ },
+ {
+ name: 'Analyses',
+ icon: '/icons/magnifying_glass_icon.svg',
+ href: '/dashboard/analyses'
+ },
+ {
+ name: 'Personnel',
+ icon: '/icons/people.svg',
+ href: '/dashboard/personnel'
+ },
+ {
+ name: 'Messages',
+ icon: '/icons/msg.svg',
+ href: '/dashboard/messages'
+ },
+ {
+ name: 'Paramètres',
+ icon: '/icons/settings.svg',
+ href: '/dashboard/settings'
+ }
+];
+
+export function getPageIndex(pathname: string) {
+ if (pathname === '/dashboard') return 0;
+ if (pathname.startsWith('/dashboard/transactions')) return 1;
+ if (pathname.startsWith('/dashboard/models')) return 2;
+ if (pathname.startsWith('/dashboard/analyses')) return 3;
+ if (pathname.startsWith('/dashboard/personnel')) return 4;
+ if (pathname.startsWith('/dashboard/messages')) return 5;
+ if (pathname.startsWith('/dashboard/settings')) return 6;
+ return -1; // Not found
+} \ No newline at end of file
diff --git a/ping/frontend/src/lib/stores/auth.ts b/ping/frontend/src/lib/stores/auth.ts
new file mode 100644
index 0000000..97acaf2
--- /dev/null
+++ b/ping/frontend/src/lib/stores/auth.ts
@@ -0,0 +1,66 @@
+import { error } from "@sveltejs/kit";
+import { get, writable } from "svelte/store";
+
+export interface IUser {
+ id: string;
+ login: string;
+ displayName: string;
+ avatar: string;
+ isAdmin: boolean;
+}
+
+export const user = writable<IUser | null>(null);
+
+export async function getUser() {
+ const userVal = get(user);
+ if (userVal) {
+ return userVal;
+ }
+ return getUpdatedUser();
+}
+
+export async function getUpdatedUser() {
+ const token = localStorage.getItem("token");
+ if (!token) {
+ localStorage.clear();
+ window.location.replace("/login");
+ throw new Error("Pas de token");
+ }
+ try {
+ const userId = JSON.parse(atob(token.split(".")[1])).sub;
+
+ const res = await authFetch(`/api/user/${userId}`);
+ if (!res.ok) {
+ throw error(res.status, "Erreur lors de la récupération de l'utilisateur");
+ }
+ const data = await res.json();
+ user.set({ ...data });
+ return data;
+ }
+ catch (e) {
+ console.error("Erreur lors de la récupération de l'ID utilisateur depuis le token", e);
+ localStorage.clear();
+ window.location.replace("/login");
+ throw new Error("Token invalide " + e);
+ }
+}
+
+// place files you want to import through the `$lib` alias in this folder.
+export function authFetch(url: string | URL, options: RequestInit = {}) {
+ const token = localStorage.getItem("token");
+ if (!token) {
+ localStorage.clear();
+ window.location.replace("/login");
+ throw new Error("Pas de token");
+ }
+
+ const mergedOptions: RequestInit = {
+ ...options,
+ headers: {
+ Authorization: `Bearer ${token}`,
+ ...(options.headers ?? {})
+ }
+ };
+
+ return fetch(url, mergedOptions);
+} \ No newline at end of file
diff --git a/ping/frontend/src/lib/stores/toast.ts b/ping/frontend/src/lib/stores/toast.ts
new file mode 100644
index 0000000..454461b
--- /dev/null
+++ b/ping/frontend/src/lib/stores/toast.ts
@@ -0,0 +1,22 @@
+import { writable } from "svelte/store";
+
+export interface Toast {
+ color?: string | undefined;
+ title?: string | undefined;
+ message?: string | undefined;
+}
+
+export const toastList = writable<Toast[]>([]);
+
+export function addToast(toast: Toast) {
+ toast ??= { color: "red", title: "Error", message: "An error occurred" };
+ toast.color ??= "red";
+ toast.title ??= "Error";
+ toast.message ??= "An error occurred";
+
+ toastList.update((list) => [...list, toast]);
+
+ setTimeout(() => {
+ toastList.update((list) => list.filter((t) => t !== toast));
+ }, 5000);
+} \ No newline at end of file