mirror of
https://github.com/exchanges-lab/view.git
synced 2026-08-05 05:36:06 +08:00
Replace custom canvas save/load with built-in save_load_adapter
- Remove custom canvas selector UI (HTML popup, CSS styles, toolbar button) - Remove all custom canvas JS functions (toggleCanvasSelector, loadCanvas, saveCanvas, autoSaveCanvas, startAutoSave, newCanvas, deleteCanvas, etc.) - Add createSaveLoadAdapter() implementing TradingView's save_load_adapter: - Chart layouts: localStorage index + backend API storage (no backend changes) - Study/Drawing/Chart templates: pure localStorage - Enable load_last_chart and auto_save_delay in widget config - Add DEV_MODE auth bypass for localhost development - Update auth-config.js with production API URL and OAuth client ID - Update README.md and README_CN.md feature descriptions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,7 @@ If deploying the backend is too difficult, you can temporarily use my public bac
|
|||||||
- [x] **Professional charting engine** — Full indicator and drawing tool support
|
- [x] **Professional charting engine** — Full indicator and drawing tool support
|
||||||
- [x] **Custom indicators** — Net Volume (NV-C) and Cumulative Volume Delta (CVD-C)
|
- [x] **Custom indicators** — Net Volume (NV-C) and Cumulative Volume Delta (CVD-C)
|
||||||
- [x] **Multi-chart layouts** — Single, vertical split, horizontal split, and 1L+2R layouts with draggable dividers
|
- [x] **Multi-chart layouts** — Single, vertical split, horizontal split, and 1L+2R layouts with draggable dividers
|
||||||
- [x] **Canvas persistence** — Named drawing canvases per symbol, with auto-save every 5 minutes
|
- [x] **Chart save/load** — Built-in save/load via `save_load_adapter` (metadata in localStorage, chart content in backend API), with auto-save
|
||||||
- [x] **Dark & Light themes** — Full theme toggle synced across widget and UI
|
- [x] **Dark & Light themes** — Full theme toggle synced across widget and UI
|
||||||
|
|
||||||
### Watchlist & Interaction
|
### Watchlist & Interaction
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@
|
|||||||
- [x] **专业图表引擎** — 完整的指标和画图工具支持
|
- [x] **专业图表引擎** — 完整的指标和画图工具支持
|
||||||
- [x] **自定义指标** — 净成交量(NV-C)和累积成交量差(CVD-C)
|
- [x] **自定义指标** — 净成交量(NV-C)和累积成交量差(CVD-C)
|
||||||
- [x] **多图表布局** — 单图、垂直分割、水平分割、1 左 + 2 右布局,分隔线可拖动
|
- [x] **多图表布局** — 单图、垂直分割、水平分割、1 左 + 2 右布局,分隔线可拖动
|
||||||
- [x] **画布持久化** — 按品种保存命名画布,每 5 分钟自动保存
|
- [x] **图表保存/加载** — 通过内置 `save_load_adapter` 实现保存/加载(元数据存 localStorage,图表内容存后端 API),支持自动保存
|
||||||
- [x] **深色/浅色主题** — 主题切换同步到图表组件和 UI
|
- [x] **深色/浅色主题** — 主题切换同步到图表组件和 UI
|
||||||
|
|
||||||
### 自选列表与交互
|
### 自选列表与交互
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
window.API_CONFIG = { baseUrl: 'https://xxx.xxx.com' };
|
window.API_CONFIG = { baseUrl: 'https://api-view.cathiefish.org' };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Google OAuth Configuration
|
* Google OAuth Configuration
|
||||||
@@ -9,7 +9,7 @@ window.API_CONFIG = { baseUrl: 'https://xxx.xxx.com' };
|
|||||||
const AUTH_CONFIG = {
|
const AUTH_CONFIG = {
|
||||||
// Client ID obtained from GCP Console
|
// Client ID obtained from GCP Console
|
||||||
// Replace with your actual Client ID
|
// Replace with your actual Client ID
|
||||||
clientId: 'xxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com',
|
clientId: '136939086552-bt28icv34nucmjna8hfndr6bopr5qcp4.apps.googleusercontent.com',
|
||||||
|
|
||||||
// Authentication success callback
|
// Authentication success callback
|
||||||
onSuccess: (user) => {
|
onSuccess: (user) => {
|
||||||
|
|||||||
+134
-362
@@ -693,109 +693,6 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Canvas Selector Popup */
|
|
||||||
.canvas-selector-popup {
|
|
||||||
display: none;
|
|
||||||
position: fixed;
|
|
||||||
background: var(--tv-color-pane-background);
|
|
||||||
border: 1px solid var(--tv-color-border);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
||||||
z-index: 1000;
|
|
||||||
min-width: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-selector-popup.is-open {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-selector-title {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--tv-color-text-secondary);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-list {
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 8px 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--tv-color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-item:hover {
|
|
||||||
background: var(--tv-color-toolbar-button-background-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-item.is-active {
|
|
||||||
background: rgba(41, 98, 255, 0.15);
|
|
||||||
color: var(--tv-color-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-item-name {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-item-delete {
|
|
||||||
opacity: 0;
|
|
||||||
color: var(--tv-color-text-secondary);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-item:hover .canvas-item-delete {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-item-delete:hover {
|
|
||||||
background: rgba(255, 0, 0, 0.2);
|
|
||||||
color: #ff5252;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
border-top: 1px solid var(--tv-color-border);
|
|
||||||
padding-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-btn {
|
|
||||||
flex: 1;
|
|
||||||
padding: 6px 12px;
|
|
||||||
border: 1px solid var(--tv-color-border);
|
|
||||||
border-radius: 4px;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--tv-color-text);
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-btn:hover {
|
|
||||||
background: var(--tv-color-toolbar-button-background-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-btn.primary {
|
|
||||||
background: var(--tv-color-blue);
|
|
||||||
border-color: var(--tv-color-blue);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.canvas-btn.primary:hover {
|
|
||||||
background: #1e53e4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Charts Layout Container */
|
/* Charts Layout Container */
|
||||||
.charts-layout {
|
.charts-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1106,21 +1003,6 @@
|
|||||||
</path>
|
</path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="toolbar-button" id="btnCanvas" onclick="toggleCanvasSelector()" title="Canvas Selector">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" width="44" height="44"
|
|
||||||
fill="currentColor">
|
|
||||||
<g clip-path="url(#a)">
|
|
||||||
<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"
|
|
||||||
d="M22 8a14 14 0 1 0 0 28 14 14 0 0 0 0-28ZM9 22a13 13 0 0 1 22.03-9.35L27.5 16.2a8 8 0 1 0 .68.73l3.55-3.55A13 13 0 1 1 9 22Zm17.79-5.1a7 7 0 1 0 .68.74l-3.62 3.6A2 2 0 0 1 22 24a2 2 0 1 1 1.25-3.56l3.54-3.54Z">
|
|
||||||
</path>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<clipPath id="a">
|
|
||||||
<path fill="currentColor" d="M0 0h44v44H0z"></path>
|
|
||||||
</clipPath>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div class="toolbar-spacer"></div>
|
<div class="toolbar-spacer"></div>
|
||||||
<button class="toolbar-button" id="btnTheme" onclick="toggleTheme()" title="Toggle Theme">
|
<button class="toolbar-button" id="btnTheme" onclick="toggleTheme()" title="Toggle Theme">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" width="44" height="44" fill="none">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44" width="44" height="44" fill="none">
|
||||||
@@ -1173,15 +1055,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Canvas Selector Popup -->
|
|
||||||
<div class="canvas-selector-popup" id="canvasSelector">
|
|
||||||
<div class="canvas-selector-title">Canvas: <span id="canvasSymbol">-</span></div>
|
|
||||||
<div class="canvas-list" id="canvasList"></div>
|
|
||||||
<div class="canvas-actions">
|
|
||||||
<button class="canvas-btn" onclick="newCanvas()">New</button>
|
|
||||||
<button class="canvas-btn primary" onclick="saveCanvas()">Save</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2069,239 +1942,138 @@
|
|||||||
selector.classList.toggle('is-open', isLayoutSelectorOpen);
|
selector.classList.toggle('is-open', isLayoutSelectorOpen);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Canvas Selector ============
|
// ============ Save/Load Adapter ============
|
||||||
let isCanvasSelectorOpen = false;
|
|
||||||
let currentCanvasNames = {}; // { chartIndex: canvasName }
|
|
||||||
|
|
||||||
function toggleCanvasSelector() {
|
|
||||||
isCanvasSelectorOpen = !isCanvasSelectorOpen;
|
|
||||||
const selector = document.getElementById('canvasSelector');
|
|
||||||
const btn = document.getElementById('btnCanvas');
|
|
||||||
|
|
||||||
if (isCanvasSelectorOpen) {
|
|
||||||
const btnRect = btn.getBoundingClientRect();
|
|
||||||
selector.style.right = (window.innerWidth - btnRect.left + 8) + 'px';
|
|
||||||
selector.style.top = btnRect.top + 'px';
|
|
||||||
loadCanvasList();
|
|
||||||
}
|
|
||||||
|
|
||||||
selector.classList.toggle('is-open', isCanvasSelectorOpen);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAuthHeaders() {
|
function getAuthHeaders() {
|
||||||
const user = getAuthUser();
|
const user = getAuthUser();
|
||||||
return user ? { 'X-User-Id': user.sub } : {};
|
return user ? { 'X-User-Id': user.sub } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadCanvasList() {
|
function createSaveLoadAdapter() {
|
||||||
const widget = tvWidgets[selectedChartIndex];
|
const CHARTS_INDEX_KEY = 'tv_charts_index';
|
||||||
if (!widget) return;
|
const STUDY_TPL_KEY = 'tv_study_templates';
|
||||||
|
const DRAWING_TPL_KEY = 'tv_drawing_templates';
|
||||||
|
const CHART_TPL_KEY = 'tv_chart_templates';
|
||||||
|
|
||||||
const symbol = widget.activeChart().symbol();
|
function getStore(key) { return JSON.parse(localStorage.getItem(key) || '[]'); }
|
||||||
document.getElementById('canvasSymbol').textContent = symbol;
|
function setStore(key, val) { localStorage.setItem(key, JSON.stringify(val)); }
|
||||||
|
function getStoreObj(key) { return JSON.parse(localStorage.getItem(key) || '{}'); }
|
||||||
|
function setStoreObj(key, val) { localStorage.setItem(key, JSON.stringify(val)); }
|
||||||
|
|
||||||
try {
|
let nextId = Date.now();
|
||||||
const res = await fetch(`${getApiBaseUrl()}/canvas/list?symbol=${symbol}`, {
|
|
||||||
headers: getAuthHeaders()
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
renderCanvasList(data.canvases, symbol);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load canvas list:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCanvasList(canvases, symbol) {
|
return {
|
||||||
const list = document.getElementById('canvasList');
|
getAllCharts() {
|
||||||
const currentName = currentCanvasNames[selectedChartIndex] || 'default';
|
return Promise.resolve(getStore(CHARTS_INDEX_KEY));
|
||||||
|
},
|
||||||
|
|
||||||
if (canvases.length === 0) {
|
saveChart(chartData) {
|
||||||
list.innerHTML = '<div style="color:var(--tv-color-text-secondary);padding:8px;">No saved canvases</div>';
|
const index = getStore(CHARTS_INDEX_KEY);
|
||||||
return;
|
const id = chartData.id || nextId++;
|
||||||
}
|
const content = chartData.content;
|
||||||
|
const symbol = chartData.symbol;
|
||||||
|
const name = chartData.name;
|
||||||
|
|
||||||
list.innerHTML = canvases.map(name => `
|
const existing = index.findIndex(c => c.id === id);
|
||||||
<div class="canvas-item ${name === currentName ? 'is-active' : ''}" onclick="loadCanvas('${symbol}', '${name}')">
|
const meta = { id, name, symbol, resolution: chartData.resolution, timestamp: Math.floor(Date.now() / 1000) };
|
||||||
<span class="canvas-item-name">${symbol}_${name}</span>
|
if (existing >= 0) index[existing] = meta;
|
||||||
<span class="canvas-item-delete" onclick="event.stopPropagation();deleteCanvas('${symbol}','${name}')">✕</span>
|
else index.push(meta);
|
||||||
</div>
|
setStore(CHARTS_INDEX_KEY, index);
|
||||||
`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCanvas(symbol, name) {
|
return fetch(`${getApiBaseUrl()}/canvas/save`, {
|
||||||
const widget = tvWidgets[selectedChartIndex];
|
|
||||||
if (!widget) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${getApiBaseUrl()}/canvas/load?symbol=${symbol}&name=${name}`, {
|
|
||||||
headers: getAuthHeaders()
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json();
|
|
||||||
widget.load(data);
|
|
||||||
// Force background color after load
|
|
||||||
widget.applyOverrides({
|
|
||||||
"paneProperties.background": "#0F0F0F",
|
|
||||||
"paneProperties.backgroundType": "solid",
|
|
||||||
"scalesProperties.backgroundColor": "#0F0F0F"
|
|
||||||
});
|
|
||||||
currentCanvasNames[selectedChartIndex] = name;
|
|
||||||
// Remember the canvas name used by this symbol
|
|
||||||
saveLastCanvasName(symbol, name);
|
|
||||||
toggleCanvasSelector();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load canvas:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveCanvas() {
|
|
||||||
const widget = tvWidgets[selectedChartIndex];
|
|
||||||
if (!widget) return;
|
|
||||||
|
|
||||||
const symbol = widget.activeChart().symbol();
|
|
||||||
const name = currentCanvasNames[selectedChartIndex] || 'default';
|
|
||||||
|
|
||||||
widget.save(async (state) => {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${getApiBaseUrl()}/canvas/save`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||||
body: JSON.stringify({ symbol, name, data: state })
|
body: JSON.stringify({ symbol, name: String(id), data: JSON.parse(content) })
|
||||||
});
|
}).then(() => id);
|
||||||
if (res.ok) {
|
},
|
||||||
// Save successful, remember the canvas name used by this symbol
|
|
||||||
saveLastCanvasName(symbol, name);
|
|
||||||
console.log(`Canvas saved: ${symbol}_${name}`);
|
|
||||||
// Update auto-save state snapshot
|
|
||||||
lastSavedStates[selectedChartIndex] = JSON.stringify(state);
|
|
||||||
toggleCanvasSelector();
|
|
||||||
} else {
|
|
||||||
console.error('Failed to save canvas, status:', res.status);
|
|
||||||
alert('Failed to save canvas. Please check server logs.');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to save canvas:', e);
|
|
||||||
alert('Failed to save canvas: ' + e.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-save related
|
getChartContent(chartId) {
|
||||||
let lastSavedStates = {};
|
const index = getStore(CHARTS_INDEX_KEY);
|
||||||
|
const chart = index.find(c => c.id == chartId);
|
||||||
async function autoSaveCanvas(index) {
|
if (!chart) return Promise.reject('Chart not found');
|
||||||
const widget = tvWidgets[index];
|
return fetch(`${getApiBaseUrl()}/canvas/load?symbol=${chart.symbol}&name=${chartId}`, {
|
||||||
if (!widget) return;
|
|
||||||
|
|
||||||
const symbol = widget.activeChart().symbol();
|
|
||||||
const name = currentCanvasNames[index] || 'default';
|
|
||||||
|
|
||||||
widget.save(async (state) => {
|
|
||||||
const stateStr = JSON.stringify(state);
|
|
||||||
if (lastSavedStates[index] === stateStr) return; // No changes
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${getApiBaseUrl()}/canvas/save`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
|
||||||
body: JSON.stringify({ symbol, name, data: state })
|
|
||||||
});
|
|
||||||
if (res.ok) {
|
|
||||||
lastSavedStates[index] = stateStr;
|
|
||||||
saveLastCanvasName(symbol, name);
|
|
||||||
console.log(`Auto-saved: ${symbol}_${name}`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Auto-save failed:', e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function startAutoSave() {
|
|
||||||
setInterval(() => {
|
|
||||||
tvWidgets.forEach((_, index) => autoSaveCanvas(index));
|
|
||||||
}, 5 * 60 * 1000); // 5 minutes
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the last used canvas name for each symbol
|
|
||||||
function saveLastCanvasName(symbol, name) {
|
|
||||||
try {
|
|
||||||
const key = 'lastCanvasNames';
|
|
||||||
const data = JSON.parse(localStorage.getItem(key) || '{}');
|
|
||||||
data[symbol.toUpperCase()] = name;
|
|
||||||
localStorage.setItem(key, JSON.stringify(data));
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to save last canvas name:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the last used canvas name for a symbol
|
|
||||||
function getLastCanvasName(symbol) {
|
|
||||||
try {
|
|
||||||
const key = 'lastCanvasNames';
|
|
||||||
const data = JSON.parse(localStorage.getItem(key) || '{}');
|
|
||||||
return data[symbol.toUpperCase()] || null;
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-load the last saved canvas
|
|
||||||
async function autoLoadCanvas(widget, index) {
|
|
||||||
const symbol = widget.activeChart().symbol();
|
|
||||||
const lastName = getLastCanvasName(symbol);
|
|
||||||
|
|
||||||
if (lastName) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${getApiBaseUrl()}/canvas/load?symbol=${symbol}&name=${lastName}`, {
|
|
||||||
headers: getAuthHeaders()
|
headers: getAuthHeaders()
|
||||||
});
|
}).then(r => r.text());
|
||||||
if (res.ok) {
|
},
|
||||||
const data = await res.json();
|
|
||||||
widget.load(data);
|
removeChart(id) {
|
||||||
widget.applyOverrides({
|
const index = getStore(CHARTS_INDEX_KEY);
|
||||||
"paneProperties.background": "#0F0F0F",
|
const chart = index.find(c => c.id == id);
|
||||||
"paneProperties.backgroundType": "solid",
|
const newIndex = index.filter(c => c.id != id);
|
||||||
"scalesProperties.backgroundColor": "#0F0F0F"
|
setStore(CHARTS_INDEX_KEY, newIndex);
|
||||||
});
|
if (chart) {
|
||||||
currentCanvasNames[index] = lastName;
|
return fetch(`${getApiBaseUrl()}/canvas/delete?symbol=${chart.symbol}&name=${id}`, {
|
||||||
console.log(`Auto-loaded canvas: ${symbol}_${lastName}`);
|
method: 'DELETE', headers: getAuthHeaders()
|
||||||
|
}).then(() => {});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
return Promise.resolve();
|
||||||
console.log(`No saved canvas found for ${symbol}`);
|
},
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function newCanvas() {
|
getAllStudyTemplates() { return Promise.resolve(getStore(STUDY_TPL_KEY)); },
|
||||||
const widget = tvWidgets[selectedChartIndex];
|
saveStudyTemplate(data) {
|
||||||
if (!widget) return;
|
const list = getStore(STUDY_TPL_KEY);
|
||||||
|
const existing = list.findIndex(t => t.name === data.name);
|
||||||
|
if (existing >= 0) list[existing] = data;
|
||||||
|
else list.push(data);
|
||||||
|
setStore(STUDY_TPL_KEY, list);
|
||||||
|
const contents = getStoreObj(STUDY_TPL_KEY + '_content');
|
||||||
|
contents[data.name] = data.content;
|
||||||
|
setStoreObj(STUDY_TPL_KEY + '_content', contents);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
removeStudyTemplate(info) {
|
||||||
|
setStore(STUDY_TPL_KEY, getStore(STUDY_TPL_KEY).filter(t => t.name !== info.name));
|
||||||
|
const contents = getStoreObj(STUDY_TPL_KEY + '_content');
|
||||||
|
delete contents[info.name];
|
||||||
|
setStoreObj(STUDY_TPL_KEY + '_content', contents);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
getStudyTemplateContent(info) {
|
||||||
|
const contents = getStoreObj(STUDY_TPL_KEY + '_content');
|
||||||
|
return Promise.resolve(contents[info.name] || '');
|
||||||
|
},
|
||||||
|
|
||||||
const symbol = widget.activeChart().symbol();
|
getDrawingTemplates(toolName) {
|
||||||
const name = await showInputModal('New Canvas Name', 'default', 'Enter canvas name...');
|
const all = getStoreObj(DRAWING_TPL_KEY);
|
||||||
if (!name) return;
|
return Promise.resolve(Object.keys(all[toolName] || {}));
|
||||||
|
},
|
||||||
|
loadDrawingTemplate(toolName, templateName) {
|
||||||
|
const all = getStoreObj(DRAWING_TPL_KEY);
|
||||||
|
return Promise.resolve((all[toolName] || {})[templateName] || '');
|
||||||
|
},
|
||||||
|
saveDrawingTemplate(toolName, templateName, content) {
|
||||||
|
const all = getStoreObj(DRAWING_TPL_KEY);
|
||||||
|
if (!all[toolName]) all[toolName] = {};
|
||||||
|
all[toolName][templateName] = content;
|
||||||
|
setStoreObj(DRAWING_TPL_KEY, all);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
removeDrawingTemplate(toolName, templateName) {
|
||||||
|
const all = getStoreObj(DRAWING_TPL_KEY);
|
||||||
|
if (all[toolName]) delete all[toolName][templateName];
|
||||||
|
setStoreObj(DRAWING_TPL_KEY, all);
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
|
||||||
currentCanvasNames[selectedChartIndex] = name;
|
getAllChartTemplates() {
|
||||||
await saveCanvas();
|
return Promise.resolve(Object.keys(getStoreObj(CHART_TPL_KEY)));
|
||||||
loadCanvasList();
|
},
|
||||||
}
|
saveChartTemplate(name, content) {
|
||||||
|
const all = getStoreObj(CHART_TPL_KEY);
|
||||||
async function deleteCanvas(symbol, name) {
|
all[name] = content;
|
||||||
if (!confirm(`Delete canvas "${symbol}_${name}"?`)) return;
|
setStoreObj(CHART_TPL_KEY, all);
|
||||||
|
return Promise.resolve();
|
||||||
try {
|
},
|
||||||
await fetch(`${getApiBaseUrl()}/canvas/delete?symbol=${symbol}&name=${name}`, {
|
getChartTemplateContent(name) {
|
||||||
method: 'DELETE',
|
const all = getStoreObj(CHART_TPL_KEY);
|
||||||
headers: getAuthHeaders()
|
return Promise.resolve({ content: all[name] || {} });
|
||||||
});
|
},
|
||||||
if (currentCanvasNames[selectedChartIndex] === name) {
|
removeChartTemplate(name) {
|
||||||
currentCanvasNames[selectedChartIndex] = 'default';
|
const all = getStoreObj(CHART_TPL_KEY);
|
||||||
}
|
delete all[name];
|
||||||
loadCanvasList();
|
setStoreObj(CHART_TPL_KEY, all);
|
||||||
} catch (e) {
|
return Promise.resolve();
|
||||||
console.error('Failed to delete canvas:', e);
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function changeLayout(layout) {
|
function changeLayout(layout) {
|
||||||
@@ -2558,7 +2330,9 @@
|
|||||||
fullscreen: false,
|
fullscreen: false,
|
||||||
autosize: true,
|
autosize: true,
|
||||||
theme: isDarkTheme ? "Dark" : "Light",
|
theme: isDarkTheme ? "Dark" : "Light",
|
||||||
load_last_chart: false,
|
save_load_adapter: createSaveLoadAdapter(),
|
||||||
|
load_last_chart: true,
|
||||||
|
auto_save_delay: 5,
|
||||||
custom_indicators_getter: (PineJS) => {
|
custom_indicators_getter: (PineJS) => {
|
||||||
window.PineJS = PineJS;
|
window.PineJS = PineJS;
|
||||||
return Promise.resolve([NetVolumeIndicator, CVDIndicator]);
|
return Promise.resolve([NetVolumeIndicator, CVDIndicator]);
|
||||||
@@ -2647,9 +2421,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
widget._ready = true;
|
widget._ready = true;
|
||||||
|
|
||||||
// Auto-load the last saved canvas
|
|
||||||
autoLoadCanvas(widget, index);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
tvWidgets[index] = widget;
|
tvWidgets[index] = widget;
|
||||||
@@ -2682,14 +2453,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close canvas selector if clicking outside
|
|
||||||
if (isCanvasSelectorOpen) {
|
|
||||||
const canvasSelector = document.getElementById('canvasSelector');
|
|
||||||
const canvasBtn = document.getElementById('btnCanvas');
|
|
||||||
if (!canvasSelector.contains(e.target) && !canvasBtn.contains(e.target)) {
|
|
||||||
toggleCanvasSelector();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Initialize Watchlist Event Listeners ============
|
// ============ Initialize Watchlist Event Listeners ============
|
||||||
@@ -2851,8 +2614,6 @@
|
|||||||
createWidget(0);
|
createWidget(0);
|
||||||
selectChart(0);
|
selectChart(0);
|
||||||
|
|
||||||
// Start auto-save
|
|
||||||
startAutoSave();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Authentication Guard ============
|
// ============ Authentication Guard ============
|
||||||
@@ -2894,20 +2655,31 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function initApp() {
|
async function initApp() {
|
||||||
const user = checkAuth();
|
// DEV_MODE: skip auth for local preview
|
||||||
if (!user) {
|
const DEV_MODE = (location.hostname === 'localhost' || location.hostname === '127.0.0.1');
|
||||||
window.location.href = './login.html';
|
if (!DEV_MODE) {
|
||||||
return;
|
const user = checkAuth();
|
||||||
|
if (!user) {
|
||||||
|
window.location.href = './login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log('Authenticated as:', user.email);
|
||||||
|
} else {
|
||||||
|
console.log('DEV_MODE: skipping auth');
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Authenticated as:', user.email);
|
|
||||||
|
|
||||||
const backendReady = await waitForBackend();
|
const backendReady = await waitForBackend();
|
||||||
if (backendReady) {
|
if (backendReady) {
|
||||||
await fetchDailyOpens();
|
await fetchDailyOpens();
|
||||||
init();
|
init();
|
||||||
} else {
|
} else {
|
||||||
alert('Backend server is not responding. Please ensure it is running.');
|
// In dev mode, still try to init even without backend
|
||||||
|
if (DEV_MODE) {
|
||||||
|
console.warn('Backend not available, initializing anyway for preview');
|
||||||
|
init();
|
||||||
|
} else {
|
||||||
|
alert('Backend server is not responding. Please ensure it is running.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -127,7 +127,7 @@
|
|||||||
const userData = parseJwt(credential);
|
const userData = parseJwt(credential);
|
||||||
|
|
||||||
// Email whitelist verification
|
// Email whitelist verification
|
||||||
const allowedEmails = ['xxx@gmail.com'];
|
const allowedEmails = ['your-email@gmail.com'];
|
||||||
if (!allowedEmails.includes(userData.email)) {
|
if (!allowedEmails.includes(userData.email)) {
|
||||||
document.getElementById('error-msg').textContent = 'Access denied: unauthorized email.';
|
document.getElementById('error-msg').textContent = 'Access denied: unauthorized email.';
|
||||||
document.getElementById('error-msg').style.display = 'block';
|
document.getElementById('error-msg').style.display = 'block';
|
||||||
|
|||||||
Reference in New Issue
Block a user