feat: live price updates on coin page
This commit is contained in:
parent
330ea7ad79
commit
0aa4849e76
5 changed files with 176 additions and 23 deletions
|
|
@ -5,7 +5,7 @@
|
||||||
import { Label } from '$lib/components/ui/label';
|
import { Label } from '$lib/components/ui/label';
|
||||||
import { Badge } from '$lib/components/ui/badge';
|
import { Badge } from '$lib/components/ui/badge';
|
||||||
import { TrendingUp, TrendingDown, Loader2 } from 'lucide-svelte';
|
import { TrendingUp, TrendingDown, Loader2 } from 'lucide-svelte';
|
||||||
import { USER_DATA } from '$lib/stores/user-data';
|
import { PORTFOLIO_DATA } from '$lib/stores/portfolio-data';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
|
|
@ -33,10 +33,9 @@
|
||||||
? Math.min(userHolding, Math.floor(Number(coin.poolCoinAmount) * 0.995))
|
? Math.min(userHolding, Math.floor(Number(coin.poolCoinAmount) * 0.995))
|
||||||
: userHolding
|
: userHolding
|
||||||
);
|
);
|
||||||
|
|
||||||
let estimatedResult = $derived(calculateEstimate(numericAmount, type, currentPrice));
|
let estimatedResult = $derived(calculateEstimate(numericAmount, type, currentPrice));
|
||||||
let hasValidAmount = $derived(numericAmount > 0);
|
let hasValidAmount = $derived(numericAmount > 0);
|
||||||
let userBalance = $derived($USER_DATA ? Number($USER_DATA.baseCurrencyBalance) : 0);
|
let userBalance = $derived($PORTFOLIO_DATA ? $PORTFOLIO_DATA.baseCurrencyBalance : 0);
|
||||||
let hasEnoughFunds = $derived(
|
let hasEnoughFunds = $derived(
|
||||||
type === 'BUY' ? numericAmount <= userBalance : numericAmount <= userHolding
|
type === 'BUY' ? numericAmount <= userBalance : numericAmount <= userHolding
|
||||||
);
|
);
|
||||||
|
|
@ -114,7 +113,7 @@
|
||||||
function setMaxAmount() {
|
function setMaxAmount() {
|
||||||
if (type === 'SELL') {
|
if (type === 'SELL') {
|
||||||
amount = maxSellableAmount.toString();
|
amount = maxSellableAmount.toString();
|
||||||
} else if ($USER_DATA) {
|
} else if ($PORTFOLIO_DATA) {
|
||||||
// For BUY, max is user's balance
|
// For BUY, max is user's balance
|
||||||
amount = userBalance.toString();
|
amount = userBalance.toString();
|
||||||
}
|
}
|
||||||
|
|
@ -164,7 +163,7 @@
|
||||||
<br />Max sellable: {maxSellableAmount.toFixed(0)} {coin.symbol} (pool limit)
|
<br />Max sellable: {maxSellableAmount.toFixed(0)} {coin.symbol} (pool limit)
|
||||||
{/if}
|
{/if}
|
||||||
</p>
|
</p>
|
||||||
{:else if $USER_DATA}
|
{:else if $PORTFOLIO_DATA}
|
||||||
<p class="text-muted-foreground text-xs">
|
<p class="text-muted-foreground text-xs">
|
||||||
Balance: ${userBalance.toFixed(6)}
|
Balance: ${userBalance.toFixed(6)}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,16 @@ export interface LiveTrade {
|
||||||
userImage?: string;
|
userImage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PriceUpdate {
|
||||||
|
coinSymbol: string;
|
||||||
|
currentPrice: number;
|
||||||
|
marketCap: number;
|
||||||
|
change24h: number;
|
||||||
|
volume24h: number;
|
||||||
|
poolCoinAmount?: number;
|
||||||
|
poolBaseCurrencyAmount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const WEBSOCKET_URL = PUBLIC_WEBSOCKET_URL;
|
const WEBSOCKET_URL = PUBLIC_WEBSOCKET_URL;
|
||||||
const RECONNECT_DELAY = 5000;
|
const RECONNECT_DELAY = 5000;
|
||||||
|
|
@ -32,10 +42,14 @@ export const liveTradesStore = writable<LiveTrade[]>([]);
|
||||||
export const allTradesStore = writable<LiveTrade[]>([]);
|
export const allTradesStore = writable<LiveTrade[]>([]);
|
||||||
export const isConnectedStore = writable<boolean>(false);
|
export const isConnectedStore = writable<boolean>(false);
|
||||||
export const isLoadingTrades = writable<boolean>(false);
|
export const isLoadingTrades = writable<boolean>(false);
|
||||||
|
export const priceUpdatesStore = writable<Record<string, PriceUpdate>>({});
|
||||||
|
|
||||||
// Comment callbacks
|
// Comment callbacks
|
||||||
const commentSubscriptions = new Map<string, (message: any) => void>();
|
const commentSubscriptions = new Map<string, (message: any) => void>();
|
||||||
|
|
||||||
|
// Price update callbacks
|
||||||
|
const priceUpdateSubscriptions = new Map<string, (priceUpdate: PriceUpdate) => void>();
|
||||||
|
|
||||||
async function loadInitialTrades(): Promise<void> {
|
async function loadInitialTrades(): Promise<void> {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
|
|
@ -115,6 +129,29 @@ function handleCommentMessage(message: any): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handlePriceUpdateMessage(message: any): void {
|
||||||
|
const priceUpdate: PriceUpdate = {
|
||||||
|
coinSymbol: message.coinSymbol,
|
||||||
|
currentPrice: message.currentPrice,
|
||||||
|
marketCap: message.marketCap,
|
||||||
|
change24h: message.change24h,
|
||||||
|
volume24h: message.volume24h,
|
||||||
|
poolCoinAmount: message.poolCoinAmount,
|
||||||
|
poolBaseCurrencyAmount: message.poolBaseCurrencyAmount
|
||||||
|
};
|
||||||
|
|
||||||
|
priceUpdatesStore.update(updates => ({
|
||||||
|
...updates,
|
||||||
|
[message.coinSymbol]: priceUpdate
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Call specific coin callback if subscribed
|
||||||
|
const callback = priceUpdateSubscriptions.get(message.coinSymbol);
|
||||||
|
if (callback) {
|
||||||
|
callback(priceUpdate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleWebSocketMessage(event: MessageEvent): void {
|
function handleWebSocketMessage(event: MessageEvent): void {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(event.data);
|
const message = JSON.parse(event.data);
|
||||||
|
|
@ -125,6 +162,10 @@ function handleWebSocketMessage(event: MessageEvent): void {
|
||||||
handleTradeMessage(message);
|
handleTradeMessage(message);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'price_update':
|
||||||
|
handlePriceUpdateMessage(message);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'ping':
|
case 'ping':
|
||||||
sendMessage({ type: 'pong' });
|
sendMessage({ type: 'pong' });
|
||||||
break;
|
break;
|
||||||
|
|
@ -202,11 +243,21 @@ function unsubscribeFromComments(coinSymbol: string): void {
|
||||||
commentSubscriptions.delete(coinSymbol);
|
commentSubscriptions.delete(coinSymbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function subscribeToPriceUpdates(coinSymbol: string, callback: (priceUpdate: PriceUpdate) => void): void {
|
||||||
|
priceUpdateSubscriptions.set(coinSymbol, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsubscribeFromPriceUpdates(coinSymbol: string): void {
|
||||||
|
priceUpdateSubscriptions.delete(coinSymbol);
|
||||||
|
}
|
||||||
|
|
||||||
export const websocketController = {
|
export const websocketController = {
|
||||||
connect,
|
connect,
|
||||||
disconnect,
|
disconnect,
|
||||||
setCoin,
|
setCoin,
|
||||||
subscribeToComments,
|
subscribeToComments,
|
||||||
unsubscribeFromComments,
|
unsubscribeFromComments,
|
||||||
|
subscribeToPriceUpdates,
|
||||||
|
unsubscribeFromPriceUpdates,
|
||||||
loadInitialTrades
|
loadInitialTrades
|
||||||
};
|
};
|
||||||
|
|
@ -183,4 +183,23 @@ export function getExpirationDate(option: string): string | null {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTimeframeInSeconds(timeframe: string): number {
|
||||||
|
switch (timeframe) {
|
||||||
|
case '1m':
|
||||||
|
return 60;
|
||||||
|
case '5m':
|
||||||
|
return 300;
|
||||||
|
case '15m':
|
||||||
|
return 900;
|
||||||
|
case '1h':
|
||||||
|
return 3600;
|
||||||
|
case '4h':
|
||||||
|
return 14400;
|
||||||
|
case '1d':
|
||||||
|
return 86400;
|
||||||
|
default:
|
||||||
|
return 60;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const formatMarketCap = formatValue;
|
export const formatMarketCap = formatValue;
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,9 @@ export async function POST({ params, request }) {
|
||||||
currentPrice: newPrice,
|
currentPrice: newPrice,
|
||||||
marketCap: Number(coinData.circulatingSupply) * newPrice,
|
marketCap: Number(coinData.circulatingSupply) * newPrice,
|
||||||
change24h: metrics.change24h,
|
change24h: metrics.change24h,
|
||||||
volume24h: metrics.volume24h
|
volume24h: metrics.volume24h,
|
||||||
|
poolCoinAmount: newPoolCoin,
|
||||||
|
poolBaseCurrencyAmount: newPoolBaseCurrency
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -325,7 +327,9 @@ export async function POST({ params, request }) {
|
||||||
currentPrice: newPrice,
|
currentPrice: newPrice,
|
||||||
marketCap: Number(coinData.circulatingSupply) * newPrice,
|
marketCap: Number(coinData.circulatingSupply) * newPrice,
|
||||||
change24h: metrics.change24h,
|
change24h: metrics.change24h,
|
||||||
volume24h: metrics.volume24h
|
volume24h: metrics.volume24h,
|
||||||
|
poolCoinAmount: newPoolCoin,
|
||||||
|
poolBaseCurrencyAmount: newPoolBaseCurrency
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,11 @@
|
||||||
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
|
import CoinIcon from '$lib/components/self/CoinIcon.svelte';
|
||||||
import { USER_DATA } from '$lib/stores/user-data';
|
import { USER_DATA } from '$lib/stores/user-data';
|
||||||
import { fetchPortfolioData } from '$lib/stores/portfolio-data';
|
import { fetchPortfolioData } from '$lib/stores/portfolio-data';
|
||||||
import { getPublicUrl } from '$lib/utils.js';
|
import { getPublicUrl, getTimeframeInSeconds } from '$lib/utils.js';
|
||||||
import { websocketController } from '$lib/stores/websocket';
|
import { websocketController, type PriceUpdate, isConnectedStore } from '$lib/stores/websocket';
|
||||||
|
|
||||||
const { data } = $props();
|
const { data } = $props();
|
||||||
const coinSymbol = data.coinSymbol;
|
const coinSymbol = data.coinSymbol;
|
||||||
|
|
||||||
let coin = $state<any>(null);
|
let coin = $state<any>(null);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let chartData = $state<any[]>([]);
|
let chartData = $state<any[]>([]);
|
||||||
|
|
@ -36,12 +35,21 @@
|
||||||
let buyModalOpen = $state(false);
|
let buyModalOpen = $state(false);
|
||||||
let sellModalOpen = $state(false);
|
let sellModalOpen = $state(false);
|
||||||
let selectedTimeframe = $state('1m');
|
let selectedTimeframe = $state('1m');
|
||||||
|
let lastPriceUpdateTime = 0;
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await loadCoinData();
|
await loadCoinData();
|
||||||
await loadUserHolding();
|
await loadUserHolding();
|
||||||
|
|
||||||
websocketController.setCoin(coinSymbol.toUpperCase());
|
websocketController.setCoin(coinSymbol.toUpperCase());
|
||||||
|
|
||||||
|
websocketController.subscribeToPriceUpdates(coinSymbol.toUpperCase(), handlePriceUpdate);
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
return () => {
|
||||||
|
websocketController.unsubscribeFromPriceUpdates(coinSymbol.toUpperCase());
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadCoinData() {
|
async function loadCoinData() {
|
||||||
|
|
@ -79,10 +87,70 @@
|
||||||
console.error('Failed to load user holding:', e);
|
console.error('Failed to load user holding:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleTradeSuccess() {
|
async function handleTradeSuccess() {
|
||||||
await Promise.all([loadCoinData(), loadUserHolding(), fetchPortfolioData()]);
|
await Promise.all([loadCoinData(), loadUserHolding(), fetchPortfolioData()]);
|
||||||
}
|
}
|
||||||
|
function handlePriceUpdate(priceUpdate: PriceUpdate) {
|
||||||
|
if (coin && priceUpdate.coinSymbol === coinSymbol.toUpperCase()) {
|
||||||
|
// throttle updates to prevent excessive UI updates, 1s interval
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastPriceUpdateTime < 1000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastPriceUpdateTime = now;
|
||||||
|
|
||||||
|
coin = {
|
||||||
|
...coin,
|
||||||
|
currentPrice: priceUpdate.currentPrice,
|
||||||
|
marketCap: priceUpdate.marketCap,
|
||||||
|
change24h: priceUpdate.change24h,
|
||||||
|
volume24h: priceUpdate.volume24h,
|
||||||
|
...(priceUpdate.poolCoinAmount !== undefined && {
|
||||||
|
poolCoinAmount: priceUpdate.poolCoinAmount
|
||||||
|
}),
|
||||||
|
...(priceUpdate.poolBaseCurrencyAmount !== undefined && {
|
||||||
|
poolBaseCurrencyAmount: priceUpdate.poolBaseCurrencyAmount
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
updateChartRealtime(priceUpdate.currentPrice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChartRealtime(newPrice: number) {
|
||||||
|
if (!candlestickSeries || !chart || chartData.length === 0) return;
|
||||||
|
|
||||||
|
const timeframeSeconds = getTimeframeInSeconds(selectedTimeframe);
|
||||||
|
const currentTime = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const currentCandleTime = Math.floor(currentTime / timeframeSeconds) * timeframeSeconds;
|
||||||
|
|
||||||
|
const lastCandle = chartData[chartData.length - 1];
|
||||||
|
|
||||||
|
if (lastCandle && lastCandle.time === currentCandleTime) {
|
||||||
|
const updatedCandle = {
|
||||||
|
time: currentCandleTime,
|
||||||
|
open: lastCandle.open,
|
||||||
|
high: Math.max(lastCandle.high, newPrice),
|
||||||
|
low: Math.min(lastCandle.low, newPrice),
|
||||||
|
close: newPrice
|
||||||
|
};
|
||||||
|
|
||||||
|
candlestickSeries.update(updatedCandle);
|
||||||
|
chartData[chartData.length - 1] = updatedCandle;
|
||||||
|
} else if (currentCandleTime > (lastCandle?.time || 0)) {
|
||||||
|
const newCandle = {
|
||||||
|
time: currentCandleTime,
|
||||||
|
open: newPrice,
|
||||||
|
high: newPrice,
|
||||||
|
low: newPrice,
|
||||||
|
close: newPrice
|
||||||
|
};
|
||||||
|
|
||||||
|
candlestickSeries.update(newCandle);
|
||||||
|
chartData.push(newCandle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleTimeframeChange(timeframe: string) {
|
async function handleTimeframeChange(timeframe: string) {
|
||||||
selectedTimeframe = timeframe;
|
selectedTimeframe = timeframe;
|
||||||
|
|
@ -110,9 +178,10 @@
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let chartContainer = $state<HTMLDivElement>();
|
let chartContainer = $state<HTMLDivElement>();
|
||||||
let chart: IChartApi | null = null;
|
let chart: IChartApi | null = null;
|
||||||
|
let candlestickSeries: any = null;
|
||||||
|
let volumeSeries: any = null;
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (chart && chartData.length > 0) {
|
if (chart && chartData.length > 0) {
|
||||||
|
|
@ -155,8 +224,7 @@
|
||||||
horzLine: { color: '#758696', width: 1, style: 2, visible: true, labelVisible: true }
|
horzLine: { color: '#758696', width: 1, style: 2, visible: true, labelVisible: true }
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
candlestickSeries = chart.addSeries(CandlestickSeries, {
|
||||||
const candlestickSeries = chart.addSeries(CandlestickSeries, {
|
|
||||||
upColor: '#26a69a',
|
upColor: '#26a69a',
|
||||||
downColor: '#ef5350',
|
downColor: '#ef5350',
|
||||||
borderVisible: true,
|
borderVisible: true,
|
||||||
|
|
@ -167,7 +235,7 @@
|
||||||
priceFormat: { type: 'price', precision: 8, minMove: 0.00000001 }
|
priceFormat: { type: 'price', precision: 8, minMove: 0.00000001 }
|
||||||
});
|
});
|
||||||
|
|
||||||
const volumeSeries = chart.addSeries(
|
volumeSeries = chart.addSeries(
|
||||||
HistogramSeries,
|
HistogramSeries,
|
||||||
{
|
{
|
||||||
priceFormat: { type: 'volume' },
|
priceFormat: { type: 'volume' },
|
||||||
|
|
@ -286,6 +354,11 @@
|
||||||
<h1 class="text-4xl font-bold">{coin.name}</h1>
|
<h1 class="text-4xl font-bold">{coin.name}</h1>
|
||||||
<div class="mt-1 flex items-center gap-2">
|
<div class="mt-1 flex items-center gap-2">
|
||||||
<Badge variant="outline" class="text-lg">*{coin.symbol}</Badge>
|
<Badge variant="outline" class="text-lg">*{coin.symbol}</Badge>
|
||||||
|
{#if $isConnectedStore}
|
||||||
|
<Badge variant="outline" class="border-green-500 text-xs text-green-500 animate-pulse">
|
||||||
|
● LIVE
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
{#if !coin.isListed}
|
{#if !coin.isListed}
|
||||||
<Badge variant="destructive">Delisted</Badge>
|
<Badge variant="destructive">Delisted</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -293,9 +366,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<p class="text-3xl font-bold">
|
<div class="relative">
|
||||||
${formatPrice(coin.currentPrice)}
|
<p class="text-3xl font-bold">
|
||||||
</p>
|
${formatPrice(coin.currentPrice)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<div class="mt-2 flex items-center gap-2">
|
<div class="mt-2 flex items-center gap-2">
|
||||||
{#if coin.change24h >= 0}
|
{#if coin.change24h >= 0}
|
||||||
<TrendingUp class="h-4 w-4 text-green-500" />
|
<TrendingUp class="h-4 w-4 text-green-500" />
|
||||||
|
|
@ -416,11 +491,10 @@
|
||||||
{/if}
|
{/if}
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
<!-- Liquidity Pool -->
|
<!-- Liquidity Pool -->
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header class="pb-4">
|
<Card.Header class="pb-4">
|
||||||
<Card.Title>Liquidity Pool</Card.Title>
|
<Card.Title class="flex items-center gap-2">Liquidity Pool</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="pt-0">
|
<Card.Content class="pt-0">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|
@ -429,7 +503,9 @@
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-muted-foreground text-sm">{coin.symbol}:</span>
|
<span class="text-muted-foreground text-sm">{coin.symbol}:</span>
|
||||||
<span class="font-mono text-sm">{formatSupply(coin.poolCoinAmount)}</span>
|
<span class="font-mono text-sm">
|
||||||
|
{formatSupply(coin.poolCoinAmount)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-muted-foreground text-sm">Base Currency:</span>
|
<span class="text-muted-foreground text-sm">Base Currency:</span>
|
||||||
|
|
@ -471,7 +547,9 @@
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="pt-0">
|
<Card.Content class="pt-0">
|
||||||
<p class="text-xl font-bold">{formatMarketCap(coin.marketCap)}</p>
|
<p class="text-xl font-bold">
|
||||||
|
{formatMarketCap(coin.marketCap)}
|
||||||
|
</p>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
|
@ -484,7 +562,9 @@
|
||||||
</Card.Title>
|
</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="pt-0">
|
<Card.Content class="pt-0">
|
||||||
<p class="text-xl font-bold">{formatMarketCap(coin.volume24h)}</p>
|
<p class="text-xl font-bold">
|
||||||
|
{formatMarketCap(coin.volume24h)}
|
||||||
|
</p>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
|
|
|
||||||
Reference in a new issue