Files
Archive/openclash/luci-app-openclash/luasrc/view/openclash/config_edit.htm
T
2026-04-07 21:19:03 +02:00

3512 lines
144 KiB
HTML

<style>
@font-face {
font-family: 'Twemoji Mozilla';
src: url('/luci-static/resources/openclash/fonts/TwemojiMozilla-flags-B12sb_Bp.woff2') format('woff2');
}
.oc[data-darkmode="true"] .config-editor-model .CodeMirror {
background: var(--bg-white);
color: var(--text-primary);
}
.oc[data-darkmode="true"] .config-editor-model .CodeMirror-gutters {
background: var(--bg-gray);
border-right: 1px solid var(--border-light);
}
.oc[data-darkmode="true"] .config-editor-model .CodeMirror-linenumber {
color: var(--text-secondary);
}
.oc[data-darkmode="true"] .config-editor-model .CodeMirror-scrollbar-filler,
.oc[data-darkmode="true"] .config-editor-model .CodeMirror-gutter-filler {
background: var(--bg-gray);
}
.oc[data-darkmode="true"] #config-mode-tabs .mode-tab {
color: var(--text-secondary);
}
.oc[data-darkmode="true"] #config-mode-tabs .mode-tab:hover {
color: var(--text-primary);
background: rgba(96, 165, 250, 0.1);
}
.oc[data-darkmode="true"] #config-mode-tabs .mode-tab.active {
background: var(--primary-color);
color: white;
}
.oc[data-darkmode="true"] #config-mergeview-container .CodeMirror-merge-gap {
background: var(--text-secondary) !important;
}
.oc[data-darkmode="true"] .oc .config-editor-content {
border-bottom: 1px solid var(--border-light);
border-top: 1px solid var(--border-light);
}
.oc[data-darkmode="true"] .overwrite-banner {
background: rgba(255,80,80,0.18);
}
.oc[data-darkmode="true"] .overwrite-banner svg {
stroke: var(--error-color);
}
.oc[data-darkmode="true"] .overwrite-banner svg circle {
stroke: var(--error-color);
fill: rgba(255,80,80,0.18);
}
.oc .config-editor-model-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 10000;
display: none;
align-items: center;
justify-content: center;
backdrop-filter: blur(2px);
}
.oc .config-editor-model-overlay.show {
display: flex;
}
.oc .config-editor-model {
background: var(--bg-white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
width: 90vw;
height: 85vh;
max-width: 1200px;
min-width: 600px;
min-height: 400px;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--border-light);
position: relative;
transition: all 0.3s ease;
}
.oc .config-editor-model.maximized {
width: 98vw !important;
height: 95vh !important;
max-width: none !important;
}
.oc .config-editor-model.minimized {
width: 70vw !important;
height: 70vh !important;
}
.oc .config-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-light);
background: var(--bg-gray);
flex-shrink: 0;
cursor: move;
user-select: none;
min-width: 0;
}
.oc .config-editor-title {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
}
.oc .config-editor-title #editTitle,
.oc .config-editor-title #config-file-name {
user-select: text;
-webkit-user-select: text;
cursor: text;
}
.oc .config-editor-title .config-file-name {
color: var(--primary-color);
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
vertical-align: bottom;
padding: 0;
}
.oc .config-editor-actions {
display: flex;
align-items: center;
gap: 12px;
}
.oc .size-btn {
width: 24px !important;
height: 24px !important;
min-width: 24px !important;
padding: 0 !important;
}
.oc .size-btn svg {
width: 12px !important;
height: 12px !important;
}
#config-mergeview-container {
width: 100%;
height: 100%;
display: block;
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: var(--bg-white);
z-index: 2;
}
.oc .config-editor-content {
flex: 1;
position: relative;
overflow: hidden;
border-bottom: 1px solid var(--border-light);
border-top: 1px solid var(--border-light);
}
.oc .config-editor-loading {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: var(--bg-white);
color: var(--text-secondary);
font-size: 14px;
}
.oc .loading-spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border-light);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.oc .config-editor-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: var(--bg-gray);
flex-shrink: 0;
position: relative;
}
.oc .config-editor-status {
font-size: 12px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 50%;
}
.oc .config-editor-help {
font-size: 11px;
color: var(--text-secondary);
opacity: 0.8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 50%;
}
.oc .config-editor-resize-handle {
position: absolute;
bottom: 0; right: 0;
width: 20px; height: 20px;
cursor: nw-resize;
background: linear-gradient(-45deg,
transparent 0%,
transparent 40%,
var(--border-color) 40%,
var(--border-color) 45%,
transparent 45%,
transparent 50%,
var(--border-color) 50%,
var(--border-color) 55%,
transparent 55%,
transparent 60%,
var(--border-color) 60%,
var(--border-color) 65%,
transparent 65%);
}
.oc #config-editor-textarea {
width: 100%;
height: 100%;
border: none;
outline: none;
resize: none;
font-size: 14px;
line-height: 1.5;
padding: 12px;
background: var(--bg-white);
color: var(--text-primary);
}
.oc .config-editor-model .CodeMirror {
height: 100%;
font-size: 14px;
line-height: 1.5;
}
.oc #config-mode-tabs {
display: flex;
justify-content: center;
align-items: center;
padding: 8px;
background: transparent;
border: none;
box-shadow: none;
border-radius: var(--radius-md);
}
.oc #config-mode-tabs .mode-tabs {
display: flex;
width: 100%;
background: var(--bg-gray);
border-radius: var(--radius-md);
padding: 4px;
gap: 4px;
margin: 0 auto;
}
.oc #config-mode-tabs .mode-tab {
flex: 1 1 0;
min-width: 0;
width: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 0;
border: none;
border-radius: calc(var(--radius-md) - 2px);
background: transparent;
color: var(--text-secondary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
box-sizing: border-box;
}
.oc #config-mode-tabs .mode-tab:hover {
color: var(--text-primary);
background: rgba(59, 130, 246, 0.1);
}
.oc #config-mode-tabs .mode-tab.active {
background: var(--primary-color);
color: white;
box-shadow: var(--shadow-sm);
}
.overwrite-banner {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
padding: 10px 20px;
background: rgba(255,80,80,0.12);
font-size: 14px;
text-align: center;
}
.overwrite-banner svg {
flex-shrink: 0;
display: block;
}
.overwrite-banner span {
flex: unset;
text-align: center;
display: inline-block;
color: var(--error-color);
line-height: 1.5;
vertical-align: middle;
}
.oc .config-editor-model .CodeMirror.zoom-75 { font-size: 10.5px; }
.oc .config-editor-model .CodeMirror.zoom-90 { font-size: 12.6px; }
.oc .config-editor-model .CodeMirror.zoom-110 { font-size: 15.4px; }
.oc .config-editor-model .CodeMirror.zoom-125 { font-size: 17.5px; }
.oc .config-editor-model .CodeMirror.zoom-150 { font-size: 21px; }
.oc .config-editor-model .CodeMirror.zoom-200 { font-size: 28px; }
.oc .config-editor-model .CodeMirror, .oc .config-editor-model .CodeMirror-line {
font-family: 'Twemoji Mozilla', "Microsoft Yahei", "sans-serif", "Helvetica Neue", "Helvetica", "Hiragino Sans GB" !important;
}
.oc .config-editor-model .CodeMirror-hints.log {
font-family: 'Twemoji Mozilla', "Open Sans", "PingFangSC-Regular", "Microsoft Yahei", "WenQuanYi Micro Hei", "Helvetica Neue", "Helvetica", "Hiragino Sans GB", "sans-serif" !important;
}
#config-mergeview-container .CodeMirror-merge,
#config-mergeview-container .CodeMirror-merge-pane,
#config-mergeview-container .CodeMirror,
#config-mergeview-container .CodeMirror-scroll {
height: 100% !important;
min-height: 0 !important;
box-sizing: border-box;
}
#config-mergeview-container .CodeMirror-merge-gap {
height: 100% !important;
min-height: 0 !important;
}
#config-mergeview-container .CodeMirror-scroll {
overflow-y: auto !important;
overflow-x: hidden !important;
}
#config-mergeview-container .CodeMirror-merge-pane {
overflow: hidden;
}
#config-mergeview-container .CodeMirror-merge-r-chunk {
background: #0095ff2e !important;
}
#config-mergeview-container .CodeMirror-merge-r-connect {
fill: #0095ff2e !important;
stroke: #0095ff2e !important;
}
#config-mergeview-container .CodeMirror-merge {
border: none !important;
}
.oc .update-row {
display: flex;
gap: 12px;
width: 100%;
max-width: 100%;
}
.oc .update-row > .form-select-wrapper {
flex: 1 1 0;
min-width: 0;
}
.oc .update-row .form-select {
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.oc .overwrite-config-dropdown {
position: relative;
width: 100%;
}
.oc .overwrite-config-dropdown-btn {
width: 100%;
min-height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
cursor: pointer;
font-size: 13px;
line-height: 1.4;
}
.oc .overwrite-config-dropdown.form-select-wrapper {
width: 100%;
}
.oc .overwrite-config-dropdown-btn.form-select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
}
.oc .overwrite-config-dropdown-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: calc(100% - 20px);
text-align: left;
}
.oc .overwrite-config-dropdown-arrow {
margin-left: 8px;
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid var(--text-secondary);
flex-shrink: 0;
}
.oc .overwrite-config-dropdown-panel {
display: none;
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 220px;
overflow-y: auto;
border: 1px solid var(--border-light);
border-radius: var(--radius-sm);
background: var(--bg-white);
box-shadow: var(--shadow-md);
z-index: 10003;
}
.oc .overwrite-config-dropdown.open .overwrite-config-dropdown-panel {
display: block;
}
.oc .overwrite-config-option {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--border-light);
background: var(--bg-white);
color: var(--text-primary);
cursor: pointer;
}
.oc .overwrite-config-option:last-child {
border-bottom: none;
}
.oc .overwrite-config-option:hover {
background: var(--primary-color);
color: var(--select-hover);
}
.oc[data-darkmode="true"] .overwrite-config-option {
background: var(--bg-gray);
border-bottom-color: var(--border-light);
}
.oc[data-darkmode="true"] .overwrite-config-option:hover {
background: var(--primary-color);
color: var(--select-hover);
}
.oc .overwrite-config-option:hover .overwrite-config-option-state {
border-color: var(--select-hover);
}
.oc[data-darkmode="true"] .overwrite-config-option-state {
background: var(--bg-white);
border-color: var(--border-color);
}
.oc .overwrite-config-option.disabled-by-all {
cursor: not-allowed;
opacity: 0.55;
background: var(--bg-gray);
color: var(--text-secondary);
}
.oc .overwrite-config-option.disabled-by-all:hover {
background: var(--bg-gray);
color: var(--text-secondary);
}
.oc .overwrite-config-option.disabled-by-all .overwrite-config-option-state {
border-color: var(--border-light);
color: transparent;
}
.oc .overwrite-config-option-left {
display: flex;
align-items: center;
min-width: 0;
flex: 1;
}
.oc .overwrite-config-option-left input[type="checkbox"] {
position: absolute;
opacity: 0;
pointer-events: none;
}
.oc .overwrite-config-option-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.oc .overwrite-config-option-state {
flex-shrink: 0;
width: 16px;
height: 16px;
border: 1px solid var(--border-color);
border-radius: 3px;
display: inline-flex;
align-items: center;
justify-content: center;
color: transparent;
background: var(--bg-white);
font-size: 12px;
line-height: 1;
}
.oc .overwrite-config-option.selected .overwrite-config-option-state {
border-color: var(--primary-color);
background: var(--primary-color);
color: var(--select-hover);
}
.oc .overwrite-config-dropdown.disabled .overwrite-config-dropdown-btn {
opacity: 0.6;
cursor: not-allowed;
}
.oc .overwrite-card-row {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
gap: 12px;
padding: 8px;
max-width: 100%;
min-width: 0;
align-items: stretch;
scrollbar-width: thin;
scrollbar-color: var(--primary-color) var(--bg-gray);
}
.oc .overwrite-card-row::-webkit-scrollbar {
height: 8px;
}
.oc .overwrite-card-row::-webkit-scrollbar-thumb {
background: var(--primary-color);
border-radius: 4px;
}
.oc .overwrite-card-row::-webkit-scrollbar-track {
background: var(--bg-gray);
border-radius: 4px;
}
.oc .sub-card.overwrite-item {
width: 190px;
max-width: 220px;
flex: 0 0 auto;
box-sizing: border-box;
gap: 12px;
height: 75px;
}
.oc .sub-card.overwrite-item,
.oc .sub-card.overwrite-item:active,
.oc .sub-card.overwrite-item:focus {
-webkit-tap-highlight-color: transparent;
}
.oc .overwrite-drag-line {
display: block;
min-width: 4px;
width: 4px;
background: var(--primary-color);
border-radius: 2px;
margin: 0 2px;
align-self: stretch;
height: auto;
}
.oc .oc-switch {
position: relative;
display: inline-block;
width: 36px;
height: 20px;
vertical-align: middle;
}
.oc .oc-switch input {
opacity: 0;
width: 0;
height: 0;
}
.oc .oc-switch-slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background-color: #ccc;
border-radius: 20px;
transition: .2s;
}
.oc .oc-switch-slider:before {
position: absolute;
content: "";
height: 16px; width: 16px;
left: 2px; bottom: 2px;
background-color: white;
border-radius: 50%;
transition: .2s;
}
.oc .oc-switch input:checked + .oc-switch-slider {
background-color: var(--primary-color, #2196F3);
}
.oc .oc-switch input:checked + .oc-switch-slider:before {
transform: translateX(16px);
}
.overwrite-add-icon {
font-size: 32px;
text-align: center;
color: var(--primary-color);
}
.overwrite-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.overwrite-title {
font-weight: bold;
font-size: 15px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 70%;
}
.overwrite-info {
font-size: 12px;
color: var(--text-secondary);
}
.overwrite-refresh-btn {
position: absolute;
right: 72px;
bottom: 8px;
}
.overwrite-gear-btn {
position: absolute;
right: 40px;
bottom: 8px;
}
.overwrite-del-btn {
position: absolute;
right: 8px;
bottom: 8px;
}
.sub-card.overwrite-item.active {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-color);
}
.sub-card.overwrite-item.dragging {
opacity: 0.5;
z-index: 1000;
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
transition: none !important;
}
@media screen and (max-width: 768px) {
.oc .config-editor-model {
width: 95vw;
height: 80vh;
min-width: 320px;
}
.oc .config-editor-actions {
gap: 5px;
}
.oc .overwrite-card-row {
gap: 6px;
}
.oc .sub-card.overwrite-item {
width: 185px;
max-width: 200px;
gap: 10px;
}
.oc .overwrite-drag-line {
margin: -2px;
width: 2px;
}
}
</style>
<div class="oc">
<div class="config-editor-model-overlay" id="config-editor-overlay">
<div class="config-editor-model" id="config-editor-model">
<div class="config-editor-header">
<div class="config-editor-title">
<span id="editTitle"><%:File Edit%>: </span>
<span class="config-file-name" id="config-file-name"><%:Loading...%></span>
</div>
<div class="config-editor-actions">
<button type="button" class="icon-btn" id="config-editor-layout" title="<%:Compare%>" style="display:none;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="8" height="18" rx="2" stroke="currentColor" stroke-width="2" fill="none"/>
<rect x="13" y="3" width="8" height="18" rx="2" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
</button>
<button type="button" class="icon-btn" id="config-editor-download" title="<%:Download%>">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7,11 12,16 17,11"></polyline>
<line x1="12" y1="2" x2="12" y2="16"></line>
</svg>
</button>
<button type="button" class="icon-btn" id="config-editor-save" title="<%:Save%>">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
<polyline points="17,21 17,13 7,13 7,21"></polyline>
<polyline points="7,3 7,8 15,8"></polyline>
</svg>
</button>
<button type="button" class="icon-btn" id="config-editor-close" title="<%:Close%>">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
<div id="overwrite-banner" class="overwrite-banner" style="display:none;">
<svg style="flex-shrink:0;" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="var(--error-color)" stroke-width="2">
<circle cx="12" cy="12" r="10" stroke="var(--error-color)" fill="rgba(255,80,80,0.12)"/>
<line x1="12" y1="5" x2="12" y2="13"/>
<circle cx="12" cy="16" r="0.5"/>
</svg>
<span>
<%:You are editing the overwrite script, please note that some settings may cause the abnormal, be careful with the modification!%>
</span>
</div>
<div id="config-mode-tabs" style="display:none;">
<div class="mode-tabs">
<button type="button" class="mode-tab active" id="tab-original-config" data-mode="original">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" style="vertical-align:middle;margin-right:4px;">
<rect x="4" y="4" width="16" height="16" rx="2" stroke="currentColor" stroke-width="2" fill="none"/>
<line x1="8" y1="8" x2="16" y2="8" stroke="currentColor" stroke-width="2"/>
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="2"/>
<line x1="8" y1="16" x2="12" y2="16" stroke="currentColor" stroke-width="2"/>
</svg>
<%:Original Config%>
</button>
<button type="button" class="mode-tab" id="tab-runtime-config" data-mode="runtime">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" style="vertical-align:middle;margin-right:4px;">
<polygon points="13 2 3 14 12 14 11 22 21 10 13 10 13 2" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
<%:Runtime Config%>
</button>
</div>
</div>
<div class="config-editor-content">
<div class="config-editor-loading" id="config-editor-loading">
<div class="loading-spinner"></div>
<span><%:Loading config file...%></span>
</div>
<textarea id="config-editor-textarea" style="display: none;"></textarea>
<div id="config-mergeview-container" style="display:none;width:100%;height:100%;"></div>
</div>
<div id="overwrite-card-bar" style="display:none;position:relative;margin:12px;">
<div class="overwrite-card-row" id="overwrite-card-list"></div>
</div>
<div class="config-editor-footer">
<div class="config-editor-status">
<span id="config-editor-status-text"><%:Ready%></span>
</div>
<div class="config-editor-help">
<span id="config-editor-help"><%:Press F11 for fullscreen, Esc to exit fullscreen, Ctrl+Mouse Wheel to zoom%></span>
</div>
<div class="config-editor-resize-handle" id="config-editor-resize-handle"></div>
</div>
</div>
</div>
</div>
<link rel="stylesheet" href="/luci-static/resources/openclash/lib/codemirror.css"/>
<link rel="stylesheet" href="/luci-static/resources/openclash/theme/material.css"/>
<link rel="stylesheet" href="/luci-static/resources/openclash/addon/fold/foldgutter.css"/>
<link rel="stylesheet" href="/luci-static/resources/openclash/addon/lint/lint.css">
<link rel="stylesheet" href="/luci-static/resources/openclash/addon/display/fullscreen.css">
<link rel="stylesheet" href="/luci-static/resources/openclash/addon/dialog/dialog.css">
<link rel="stylesheet" href="/luci-static/resources/openclash/addon/search/matchesonscrollbar.css">
<script src="/luci-static/resources/openclash/lib/codemirror.js"></script>
<script src="/luci-static/resources/openclash/mode/yaml/yaml.js"></script>
<script src="/luci-static/resources/openclash/mode/shell/shell.js"></script>
<script src="/luci-static/resources/openclash/mode/properties/properties.js"></script>
<script src="/luci-static/resources/openclash/addon/fold/foldcode.js"></script>
<script src="/luci-static/resources/openclash/addon/fold/foldgutter.js"></script>
<script src="/luci-static/resources/openclash/addon/fold/indent-fold.js"></script>
<script src="/luci-static/resources/openclash/addon/edit/matchbrackets.js"></script>
<script src="/luci-static/resources/openclash/addon/selection/active-line.js"></script>
<script src="/luci-static/resources/openclash/addon/lint/lint.js"></script>
<script src="/luci-static/resources/openclash/addon/lint/yaml-lint.js"></script>
<script src="/luci-static/resources/openclash/addon/lint/js-yaml.min.js"></script>
<script src="/luci-static/resources/openclash/addon/display/fullscreen.js"></script>
<script src="/luci-static/resources/openclash/addon/display/autorefresh.js"></script>
<script src="/luci-static/resources/openclash/addon/dialog/dialog.js"></script>
<script src="/luci-static/resources/openclash/addon/search/searchcursor.js"></script>
<script src="/luci-static/resources/openclash/addon/search/search.js"></script>
<script src="/luci-static/resources/openclash/addon/scroll/annotatescrollbar.js"></script>
<script src="/luci-static/resources/openclash/addon/search/matchesonscrollbar.js"></script>
<script src="/luci-static/resources/openclash/addon/search/jump-to-line.js"></script>
<script src="/luci-static/resources/openclash/addon/merge/diff_match_patch.js"></script>
<script src="/luci-static/resources/openclash/addon/merge/merge.js"></script>
<link rel="stylesheet" href="/luci-static/resources/openclash/addon/merge/merge.css">
<script type="text/javascript">
var levelTranslations = {
'info': '<%:Info%>',
'warning': '<%:Warning%>',
'error': '<%:Error%>',
'debug': '<%:Debug%>',
'tip': '<%:Tip%>',
'watchdog': '<%:Watchdog%>',
'fatal': '<%:Fatal%>'
};
var ConfigEditor = {
overlay: null,
model: null,
editorInstance: null,
originalContent: '',
isModified: false,
currentZoom: 100,
currentConfigFile: '',
zoomLevels: [75, 90, 100, 110, 125, 150, 200],
isOverwrite: false,
currentViewMode: 'original',
runtimeContent: '',
mergeViewActive: false,
SVG_COMPARE: `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="8" height="18" rx="2" stroke="currentColor" stroke-width="2" fill="none"/>
<rect x="13" y="3" width="8" height="18" rx="2" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
`,
SVG_RESTORE: `
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
`,
overwriteFiles: [],
overwriteSubInfo: {},
overwriteCardBar: null,
overwriteDrag: {
dragging: null,
startIndex: null,
touchTimer: null,
isTouchDragging: false,
touchDraggingMoved: false,
touchDraggingCard: null,
startTouch: null
},
init: function() {
this.overlay = document.getElementById('config-editor-overlay');
this.model = document.getElementById('config-editor-model');
this.overwriteCardBar = document.getElementById('overwrite-card-bar');
this.mergeViewActive = false;
if (!this.overlay || !this.model) {
return;
}
this.bindEvents();
},
bindEvents: function() {
var self = this;
document.getElementById('config-editor-save').addEventListener('click', function() {
self.saveConfigContent();
});
document.getElementById('config-editor-download').addEventListener('click', function() {
self.downloadConfigContent();
});
document.getElementById('config-editor-close').addEventListener('click', function() {
self.closeEditor();
});
document.addEventListener('keydown', function(e) {
if (!self.overlay.classList.contains('show')) return;
if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) {
e.preventDefault();
self.zoomIn();
} else if ((e.ctrlKey || e.metaKey) && e.key === '-') {
e.preventDefault();
self.zoomOut();
} else if ((e.ctrlKey || e.metaKey) && e.key === '0') {
e.preventDefault();
self.resetZoom();
} else if (e.key === 'Escape' && (!self.editorInstance || !self.editorInstance.getOption("fullScreen"))) {
self.closeEditor();
}
});
this.overlay.addEventListener('wheel', function(e) {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
if (e.deltaY < 0) {
self.zoomIn();
} else {
self.zoomOut();
}
}
});
var tabOriginal = document.getElementById('tab-original-config');
var tabRuntime = document.getElementById('tab-runtime-config');
if (tabOriginal && tabRuntime) {
tabOriginal.addEventListener('click', function() {
if (self.currentViewMode !== 'original') {
self.currentViewMode = 'original';
self.loadConfigContent();
self.updateModeTabs();
}
});
tabRuntime.addEventListener('click', function() {
if (self.currentViewMode !== 'runtime') {
self.currentViewMode = 'runtime';
self.loadConfigContent();
self.updateModeTabs();
}
});
};
var layoutBtn = document.getElementById('config-editor-layout');
if (layoutBtn) {
layoutBtn.addEventListener('click', function() {
if (!self.mergeViewActive) {
self.showMergeView();
self.currentViewMode = 'original';
layoutBtn.title = "<%:Restore%>";
layoutBtn.innerHTML = self.SVG_RESTORE;
} else {
self.hideMergeView();
layoutBtn.title = "<%:Compare%>";
layoutBtn.innerHTML = self.SVG_COMPARE;
}
});
};
this.makeDraggable();
this.makeResizable();
},
show: function(configFile) {
this.isOverwrite = false;
this.currentViewMode = 'original';
this.currentConfigFile = '';
this.originalContent = '';
this.runtimeContent = '';
this.isModified = false;
this.mergeViewActive = false;
if (this.editorInstance) {
if (this.editorInstance.toTextArea) this.editorInstance.toTextArea();
this.editorInstance = null;
}
var textarea = document.getElementById('config-editor-textarea');
if (textarea) {
textarea.value = '';
textarea.style.display = 'none';
}
var loadingDiv = document.getElementById('config-editor-loading');
if (loadingDiv) loadingDiv.style.display = 'flex';
var mergeview = document.getElementById('config-mergeview-container');
if (mergeview) mergeview.style.display = 'none';
var banner = document.getElementById('overwrite-banner');
if (banner) banner.style.display = 'none';
var tabs = document.getElementById('config-mode-tabs');
if (tabs) tabs.style.display = 'flex';
var layoutBtn = document.getElementById('config-editor-layout');
if (layoutBtn) layoutBtn.style.display = 'inline-flex';
if (!configFile) {
alert('<%:Please select a config file first%>');
return;
}
this.currentConfigFile = configFile;
this.overlay.classList.add('show');
this.model.classList.remove('maximized');
this.model.classList.remove('minimized');
var editTitle = document.getElementById('editTitle');
if (editTitle) {
editTitle.textContent = '<%:File Edit%>: ';
}
var configNameElement = document.getElementById('config-file-name');
if (configNameElement) {
configNameElement.textContent = this.formatDisplayName(configFile);
}
this.hideMergeView();
this.updateModeTabs();
this.loadConfigContent();
},
showOverwrite: function() {
this.isOverwrite = true;
this.currentViewMode = 'original';
this.originalContent = '';
this.runtimeContent = '';
this.isModified = false;
this.mergeViewActive = false;
if (this.editorInstance) {
if (this.editorInstance.toTextArea) this.editorInstance.toTextArea();
this.editorInstance = null;
}
var textarea = document.getElementById('config-editor-textarea');
if (textarea) {
textarea.value = '';
textarea.style.display = 'none';
}
var loadingDiv = document.getElementById('config-editor-loading');
if (loadingDiv) loadingDiv.style.display = 'flex';
var mergeview = document.getElementById('config-mergeview-container');
if (mergeview) mergeview.style.display = 'none';
var banner = document.getElementById('overwrite-banner');
if (banner) banner.style.display = 'flex';
var tabs = document.getElementById('config-mode-tabs');
if (tabs) tabs.style.display = 'none';
var layoutBtn = document.getElementById('config-editor-layout');
if (layoutBtn) layoutBtn.style.display = 'none';
if (!this.currentConfigFile) {
this.currentConfigFile = '/etc/openclash/custom/openclash_custom_overwrite.sh';
}
this.overlay.classList.add('show');
this.model.classList.remove('maximized');
this.model.classList.remove('minimized');
var editTitle = document.getElementById('editTitle');
if (editTitle) {
editTitle.textContent = '<%:Overwrite Edit%>: ';
}
if (this.overwriteCardBar) this.overwriteCardBar.style.display = 'block';
this.loadOverwriteFiles();
var configNameElement = document.getElementById('config-file-name');
if (configNameElement) {
configNameElement.textContent = this.formatDisplayName(this.currentConfigFile);
}
this.hideMergeView();
this.loadConfigContent();
},
hide: function() {
this.overlay.classList.remove('show');
if (this.editorInstance) {
if (this.editorInstance.toTextArea) this.editorInstance.toTextArea();
this.editorInstance = null;
}
this.isModified = false;
this.originalContent = '';
this.overwriteCardBar.style.display = 'none';
if (!this.isOverwrite) {
this.currentConfigFile = '';
}
var loadingDiv = document.getElementById('config-editor-loading');
var textarea = document.getElementById('config-editor-textarea');
var mergeview = document.getElementById('config-mergeview-container');
var editor_help = document.getElementById('config-editor-help');
var layoutBtn = document.getElementById('config-editor-layout');
if (loadingDiv) loadingDiv.style.display = 'flex';
if (textarea) textarea.style.display = 'none';
if (mergeview) mergeview.style.display = 'none';
if (layoutBtn) {
layoutBtn.classList.remove('active');
layoutBtn.title = "<%:Compare%>";
layoutBtn.innerHTML = this.SVG_COMPARE;
}
if (editor_help) editor_help.textContent = '<%:Press F11 for fullscreen, Esc to exit fullscreen, Ctrl+Mouse Wheel to zoom%>';
},
formatDisplayName: function(fileName) {
if (!fileName) return '<%:Unknown%>';
if (fileName === '/etc/openclash/custom/openclash_custom_overwrite.sh') {
return 'openclash_custom_overwrite.sh';
}
var name = fileName.split('/').pop().split('\\').pop();
return name;
},
updateModeTabs: function() {
var tabOriginal = document.getElementById('tab-original-config');
var tabRuntime = document.getElementById('tab-runtime-config');
var saveBtn = document.getElementById('config-editor-save');
if (this.isOverwrite) {
if (tabOriginal && tabRuntime) {
tabOriginal.classList.remove('active');
tabRuntime.classList.remove('active');
}
if (saveBtn) {
saveBtn.disabled = !this.isModified;
saveBtn.style.opacity = this.isModified ? '1' : '0.5';
saveBtn.style.cursor = this.isModified ? 'pointer' : 'not-allowed';
}
return;
}
if (tabOriginal && tabRuntime) {
if (this.currentViewMode === 'original') {
tabOriginal.classList.add('active');
tabRuntime.classList.remove('active');
if (saveBtn) {
saveBtn.disabled = !this.isModified;
saveBtn.style.opacity = this.isModified ? '1' : '0.5';
saveBtn.style.cursor = this.isModified ? 'pointer' : 'not-allowed';
}
} else {
tabOriginal.classList.remove('active');
tabRuntime.classList.add('active');
if (saveBtn) {
saveBtn.disabled = true;
saveBtn.style.opacity = '0.5';
saveBtn.style.cursor = 'not-allowed';
}
}
}
},
loadConfigContent: function() {
var self = this;
var statusText = document.getElementById('config-editor-status-text');
var loadingDiv = document.getElementById('config-editor-loading');
var textarea = document.getElementById('config-editor-textarea');
var mergeview = document.getElementById('config-mergeview-container');
if (mergeview) mergeview.style.display = 'none';
if (textarea) textarea.style.display = 'block';
statusText.textContent = '<%:Loading...%>';
var url, mode;
if (this.isOverwrite) {
var file = this.currentConfigFile || '/etc/openclash/custom/openclash_custom_overwrite.sh';
url = '/cgi-bin/luci/admin/services/openclash/config_file_read?config_file=' + encodeURIComponent(file);
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
mode = "text/yaml";
} else if (file.endsWith('.sh')) {
mode = "text/x-sh";
} else {
mode = "text/x-properties";
}
} else if (this.currentViewMode === 'runtime') {
var runtimePath = '/etc/openclash/' + encodeURIComponent(this.formatDisplayName(this.currentConfigFile));
url = '/cgi-bin/luci/admin/services/openclash/config_file_read?config_file=' + runtimePath;
mode = "text/yaml";
} else {
url = '/cgi-bin/luci/admin/services/openclash/config_file_read?config_file=' + encodeURIComponent(this.currentConfigFile);
mode = "text/yaml";
}
function renderEditor(content, mode, readOnly, lint) {
loadingDiv.style.display = 'none';
textarea.value = content;
textarea.style.display = 'block';
if (self.editorInstance) {
if (self.editorInstance.toTextArea) self.editorInstance.toTextArea();
self.editorInstance = null;
}
self.editorInstance = CodeMirror.fromTextArea(textarea, {
mode: mode,
autoRefresh: true,
styleActiveLine: true,
lineNumbers: true,
theme: "material",
lineWrapping: true,
matchBrackets: true,
foldGutter: true,
lint: lint,
readOnly: readOnly,
gutters: lint
? ["CodeMirror-linenumbers", "CodeMirror-foldgutter", "CodeMirror-lint-markers"]
: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"],
extraKeys: {
"F11": function(cm) {
cm.setOption("fullScreen", !cm.getOption("fullScreen"));
},
"Esc": function(cm) {
if (cm.getOption("fullScreen")) {
cm.setOption("fullScreen", false);
}
},
"Tab": function(cm) {
if (cm.somethingSelected()) {
cm.indentSelection('add');
} else {
var spaces = Array(cm.getOption("indentUnit") + 1).join(" ");
cm.replaceSelection(spaces);
}
},
"Ctrl-S": function(cm) {
if (!readOnly) self.saveConfigContent();
}
}
});
self.editorInstance.setSize('100%', '100%');
self.editorInstance.setValue(content);
self.editorInstance.refresh();
if (!readOnly) {
self.editorInstance.on("change", function() {
self.isModified = self.editorInstance.getValue() !== self.originalContent;
self.updateSaveButtonState();
});
}
}
if (!this.isOverwrite) {
if (this.currentViewMode === 'original' && this.originalContent) {
renderEditor(this.originalContent, "text/yaml", false, true);
statusText.textContent = '<%:Ready%>';
self.updateModeTabs();
return;
}
if (this.currentViewMode === 'runtime' && this.runtimeContent) {
renderEditor(this.runtimeContent, "text/yaml", true, false);
statusText.textContent = '<%:Runtime config (read only)%>';
self.updateModeTabs();
return;
}
}
fetch(url)
.then(function(response) {
if (!response.ok) {
throw new Error('HTTP error! status: ' + response.status);
}
return response.json();
})
.then(function(data) {
if (data.content !== undefined) {
if (self.currentViewMode === 'runtime' && !self.isOverwrite) {
self.runtimeContent = data.content;
renderEditor(self.runtimeContent, "text/yaml", true, false);
statusText.textContent = '<%:Runtime config (read only)%>';
} else {
self.originalContent = data.content;
renderEditor(self.originalContent, mode, self.isOverwrite ? false : false, !self.isOverwrite);
statusText.textContent = '<%:Ready%>';
}
self.updateModeTabs();
} else {
throw new Error('Invalid response data');
}
})
.catch(function(error) {
loadingDiv.querySelector('span').textContent = '<%:Failed to load config file%>';
statusText.textContent = '<%:Load failed%>';
});
},
showMergeView: function() {
var self = this;
if (this.isOverwrite) return;
var container = document.getElementById('config-mergeview-container');
var textarea = document.getElementById('config-editor-textarea');
var loadingDiv = document.getElementById('config-editor-loading');
var tabs = document.getElementById('config-mode-tabs');
var editor_help = document.getElementById('config-editor-help');
var statusText = document.getElementById('config-editor-status-text');
if (tabs) tabs.style.display = 'none';
if (textarea) textarea.style.display = 'none';
if (loadingDiv) loadingDiv.style.display = 'none';
if (container) container.style.display = 'block';
if (statusText) statusText.textContent = '<%:Loading...%>';
if (editor_help) editor_help.textContent = '<%:Press F10 to toggle differences, F11 for fullscreen, Esc to exit fullscreen, Ctrl+Mouse Wheel to zoom%>';
var getOriginal = function() {
return new Promise(function(resolve, reject) {
if (self.originalContent) return resolve(self.originalContent);
var url = '/cgi-bin/luci/admin/services/openclash/config_file_read?config_file=' + encodeURIComponent(self.currentConfigFile);
fetch(url).then(function(r){return r.json()}).then(function(data){
resolve(data.content || '');
}).catch(function(){resolve('')});
});
};
var getRuntime = function() {
return new Promise(function(resolve, reject) {
if (self.runtimeContent) return resolve(self.runtimeContent);
var runtimePath = '/etc/openclash/' + encodeURIComponent(self.formatDisplayName(self.currentConfigFile));
var url = '/cgi-bin/luci/admin/services/openclash/config_file_read?config_file=' + runtimePath;
fetch(url).then(function(r){return r.json()}).then(function(data){
resolve(data.content || '');
}).catch(function(){resolve('')});
});
};
let showDifferences = true;
Promise.all([getOriginal(), getRuntime()]).then(function(contents){
var original = contents[0] || '';
var runtime = contents[1] || '';
container.innerHTML = '';
if (self.editorInstance && self.editorInstance.toTextArea) self.editorInstance.toTextArea();
self.editorInstance = CodeMirror.MergeView(container, {
value: original,
orig: runtime,
mode: "text/yaml",
theme: "material",
lineNumbers: true,
autoRefresh: true,
styleActiveLine: true,
lineWrapping: true,
matchBrackets: true,
foldGutter: true,
lint: true,
highlightDifferences: showDifferences,
connect: null,
collapseIdentical: false,
readOnly: false,
gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter", "CodeMirror-lint-markers"],
extraKeys: {
"F10": function() {
showDifferences = !showDifferences;
if (self.editorInstance && self.editorInstance.setShowDifferences) {
self.editorInstance.setShowDifferences(showDifferences);
}
},
"F11": function(cm) {
cm.setOption("fullScreen", !cm.getOption("fullScreen"));
},
"Esc": function(cm) {
if (cm.getOption("fullScreen")) cm.setOption("fullScreen", false);
},
"Tab": function(cm) {
if (cm.somethingSelected()) {
cm.indentSelection('add');
} else {
var spaces = Array(cm.getOption("indentUnit") + 1).join(" ");
cm.replaceSelection(spaces);
}
},
"Ctrl-S": function(cm) {
self.saveConfigContent();
}
}
});
var leftEditor = self.editorInstance.edit;
if (leftEditor) {
leftEditor.on("change", function() {
self.isModified = leftEditor.getValue() !== self.originalContent;
self.updateSaveButtonState();
});
}
if (self.editorInstance.editor)
self.editorInstance.editor().setSize('100%', '100%');
if (self.editorInstance.rightOriginal && self.editorInstance.rightOriginal())
self.editorInstance.rightOriginal().setSize('100%', '100%');
self.mergeViewActive = true;
if (statusText) statusText.textContent = '<%:Compare mode: left(Original Config), right(Runtime Config)%>';
var layoutBtn = document.getElementById('config-editor-layout');
if (layoutBtn) layoutBtn.classList.add('active');
});
},
hideMergeView: function() {
var container = document.getElementById('config-mergeview-container');
var textarea = document.getElementById('config-editor-textarea');
var tabs = document.getElementById('config-mode-tabs');
var editor_help = document.getElementById('config-editor-help');
var layoutBtn = document.getElementById('config-editor-layout');
if (container) {
container.innerHTML = '';
container.style.display = 'none';
}
if (textarea) textarea.style.display = 'block';
if (!this.isOverwrite && tabs) tabs.style.display = 'flex';
this.mergeViewActive = false;
this.loadConfigContent();
if (layoutBtn) {
layoutBtn.classList.remove('active');
layoutBtn.title = "<%:Compare%>";
layoutBtn.innerHTML = this.SVG_COMPARE;
}
if (editor_help) editor_help.textContent = '<%:Press F11 for fullscreen, Esc to exit fullscreen, Ctrl+Mouse Wheel to zoom%>';
},
saveConfigContent: function() {
if (!this.editorInstance || !this.isModified) {
return;
}
var self = this;
var statusText = document.getElementById('config-editor-status-text');
var saveBtn = document.getElementById('config-editor-save');
statusText.textContent = '<%:Saving...%>';
saveBtn.disabled = true;
var content;
if (this.mergeViewActive && this.editorInstance && this.editorInstance.edit) {
content = this.editorInstance.edit.getValue();
} else {
content = this.editorInstance.getValue();
}
if (!content) {
saveBtn.disabled = false;
statusText.textContent = '<%:Save failed%>';
alert('<%:Config file content is empty%>');
return;
}
var formData = new FormData();
if (this.isOverwrite) {
formData.append('config_file', this.currentConfigFile || '/etc/openclash/custom/openclash_custom_overwrite.sh');
} else {
formData.append('config_file', this.currentConfigFile);
}
formData.append('content', content);
fetch('/cgi-bin/luci/admin/services/openclash/config_file_save', {
method: 'POST',
body: formData
})
.then(function(response) {
if (!response.ok) {
throw new Error('HTTP error! status: ' + response.status);
}
return response.json();
})
.then(function(data) {
saveBtn.disabled = false;
if (data.status === 'success') {
self.originalContent = content;
self.isModified = false;
self.updateSaveButtonState();
statusText.textContent = '<%:Saved successfully%>';
setTimeout(function() {
if (statusText && statusText.textContent === '<%:Saved successfully%>') {
statusText.textContent = '<%:Ready%>';
}
}, 3000);
} else {
statusText.textContent = '<%:Save failed%>';
alert('<%:Failed to save config file:%> ' + (data.message || '<%:Unknown error%>'));
setTimeout(function() {
if (statusText && statusText.textContent === '<%:Save failed%>') {
statusText.textContent = '<%:Ready%>';
}
}, 3000);
}
})
.catch(function(error) {
saveBtn.disabled = false;
statusText.textContent = '<%:Save failed%>';
alert('<%:Save config failed:%> ' + error.message);
setTimeout(function() {
if (statusText && statusText.textContent === '<%:Save failed%>') {
statusText.textContent = '<%:Ready%>';
}
}, 3000);
});
},
downloadConfigContent: function() {
if (!this.editorInstance) {
alert('<%:Editor not ready%>');
return;
}
var content;
if (this.mergeViewActive && this.editorInstance && this.editorInstance.edit) {
content = this.editorInstance.edit.getValue();
} else {
content = this.editorInstance.getValue();
}
var filename;
if (this.isOverwrite) {
filename = this.formatDisplayName(this.currentConfigFile);
if (!filename) filename = 'openclash_custom_overwrite.sh';
} else {
filename = this.formatDisplayName(this.currentConfigFile);
if (!filename.toLowerCase().endsWith('.yaml') && !filename.toLowerCase().endsWith('.yml')) {
filename += '.yaml';
}
}
try {
var blob = new Blob([content], { type: 'text/yaml;charset=utf-8' });
var url = window.URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
var statusText = document.getElementById('config-editor-status-text');
if (statusText) {
var originalText = statusText.textContent;
statusText.textContent = '<%:Download started%>';
setTimeout(function() {
if (statusText && statusText.textContent === '<%:Download started%>') {
statusText.textContent = originalText;
}
}, 2000);
}
} catch (error) {
alert('<%:Download failed:%> ' + error.message);
}
},
updateSaveButtonState: function() {
this.updateModeTabs();
},
loadOverwriteFiles: function() {
var self = this;
fetch('/cgi-bin/luci/admin/services/openclash/overwrite_file_list')
.then(r => r.json())
.then(function(data) {
var files = [];
if (data.overwrite_files) {
files = data.overwrite_files.filter(function(f) {
return (f.path && (f.path.indexOf('/etc/openclash/overwrite/') === 0 || f.path === '/etc/openclash/custom/openclash_custom_overwrite.sh'));
});
}
files = files.filter(f => f.path !== '/etc/openclash/custom/openclash_custom_overwrite.sh');
files.unshift({
path: '/etc/openclash/custom/openclash_custom_overwrite.sh',
name: 'openclash_custom_overwrite.sh'
});
self.overwriteFiles = files;
self.loadOverwriteSubInfo();
});
},
loadOverwriteSubInfo: function() {
var self = this;
fetch('/cgi-bin/luci/admin/services/openclash/overwrite_subscribe_info')
.then(r => r.json())
.then(function(data) {
if (data.status === 'success') {
self.overwriteSubInfo = (data && data.data) ? data.data : {};
Object.keys(self.overwriteSubInfo).forEach(function(key) {
var subInfo = self.overwriteSubInfo[key] || {};
subInfo.config = self.parseOverwriteConfigValue(subInfo.config);
self.overwriteSubInfo[key] = subInfo;
});
self.overwriteFiles.sort(function(a, b) {
var an = a.name || (a.path ? a.path.split('/').pop() : '');
var bn = b.name || (b.path ? b.path.split('/').pop() : '');
var ao = self.overwriteSubInfo[an] && self.overwriteSubInfo[an].order ? parseInt(self.overwriteSubInfo[an].order) : 0;
var bo = self.overwriteSubInfo[bn] && self.overwriteSubInfo[bn].order ? parseInt(self.overwriteSubInfo[bn].order) : 0;
return ao - bo;
});
self.renderOverwriteCards();
} else {
var statusText = document.getElementById('overwrite-edit-status-text');
statusText.textContent = '<%:Failed to save subscription info:%> ' + (data.message || '');
}
});
},
renderOverwriteCards: function() {
var self = this;
var bar = this.overwriteCardBar;
if (!bar) return;
var list = document.getElementById('overwrite-card-list');
if (!list) return;
list.innerHTML = '';
var statusText = document.getElementById('config-editor-status-text');
var addCard = document.createElement('div');
addCard.className = 'sub-card overwrite-item';
addCard.style.cursor = 'pointer';
addCard.innerHTML = '<div class="overwrite-add-icon">+</div>';
addCard.title = '<%:Add New File%>';
addCard.onclick = function() {
ConfigEditor.showAddOverwritemodel();
};
list.appendChild(addCard);
var customIdx = self.overwriteFiles.findIndex(f => (f.name === 'openclash_custom_overwrite.sh' || (f.path && f.path.endsWith('/openclash_custom_overwrite.sh'))));
var customFile = customIdx !== -1 ? self.overwriteFiles[customIdx] : null;
if (customFile) {
var name = customFile.name || (customFile.path ? customFile.path.split('/').pop() : '');
var card = document.createElement('div');
card.className = 'sub-card overwrite-item';
card.dataset.file = customFile.path;
card.dataset.index = 'custom';
if (self.currentConfigFile === customFile.path) {
card.classList.add('active');
}
var titleRow = document.createElement('div');
titleRow.className = 'overwrite-title-row';
var title = document.createElement('div');
title.textContent = name;
title.className = 'overwrite-title';
title.title = name;
titleRow.appendChild(title);
card.appendChild(titleRow);
var sub = self.overwriteSubInfo[name] || {};
var info = document.createElement('div');
info.className = 'overwrite-info';
info.textContent = sub.url ? '<%:Subscription%>' : '<%:Local File%>';
card.appendChild(info);
card.onclick = function(e) {
if (self.currentConfigFile !== customFile.path) {
self.currentConfigFile = customFile.path;
self.loadConfigContent();
self.renderOverwriteCards();
var configNameElement = document.getElementById('config-file-name');
if (configNameElement) {
configNameElement.textContent = self.formatDisplayName(customFile.path);
}
}
};
card.draggable = false;
list.appendChild(card);
}
var files = self.overwriteFiles.filter((f, i) => i !== customIdx);
self.overwriteFileCardCount = files.length;
files.forEach(function(file, idx) {
var name = file.name || (file.path ? file.path.split('/').pop() : '');
var card = document.createElement('div');
card.className = 'sub-card overwrite-item';
card.dataset.file = file.path;
card.dataset.index = idx;
if (self.currentConfigFile === file.path) {
card.classList.add('active');
}
var titleRow = document.createElement('div');
titleRow.className = 'overwrite-title-row';
var title = document.createElement('div');
title.textContent = name;
title.className = 'overwrite-title';
card.appendChild(title);
var sub = self.overwriteSubInfo[name] || {};
var enable = typeof sub.enable !== 'undefined' ? sub.enable : 0;
var switchLabel = document.createElement('label');
switchLabel.className = 'oc-switch';
var switchInput = document.createElement('input');
switchInput.type = 'checkbox';
switchInput.checked = enable == 1 ? true : false;
switchInput.onchange = function(e) {
e.stopPropagation();
var newEnable = switchInput.checked ? 1 : 0;
var formData = new FormData();
formData.append('filename', name);
formData.append('type', sub.type || 'file');
formData.append('url', sub.url || '');
formData.append('update_days', sub.update_days || '');
formData.append('update_hour', sub.update_hour || '');
formData.append('order', (typeof sub.order !== 'undefined' && sub.order !== null && sub.order !== '') ? sub.order : idx);
formData.append('param', sub.param || '');
formData.append('config', self.parseOverwriteConfigValue(sub.config).join('\n'));
formData.append('enable', newEnable);
fetch('/cgi-bin/luci/admin/services/openclash/overwrite_subscribe_info', {
method: 'POST',
body: formData
}).then(r=>r.json()).then(function(data){
if (data.status === 'success') {
self.overwriteSubInfo[name] = self.overwriteSubInfo[name] || {};
self.overwriteSubInfo[name].enable = newEnable;
} else {
statusText.textContent = '<%:Failed to update enable status%>';
switchInput.checked = !switchInput.checked;
}
}).catch(function(){
statusText.textContent = '<%:Failed to update enable status%>';
switchInput.checked = !switchInput.checked;
});
};
switchInput.onclick = function(e) { e.stopPropagation(); };
switchInput.onmousedown = function(e) { e.stopPropagation(); };
switchInput.ontouchstart = function(e) { e.stopPropagation(); };
switchLabel.onclick = function(e) { e.stopPropagation(); };
switchLabel.onmousedown = function(e) { e.stopPropagation(); };
switchLabel.ontouchstart = function(e) { e.stopPropagation(); };
var switchSpan = document.createElement('span');
switchSpan.className = 'oc-switch-slider';
switchLabel.appendChild(switchInput);
switchLabel.appendChild(switchSpan);
titleRow.appendChild(title);
titleRow.appendChild(switchLabel);
card.appendChild(titleRow);
var info = document.createElement('div');
info.className = 'overwrite-info';
info.textContent = sub.url ? '<%:Subscription%>' : '<%:Local File%>';
card.appendChild(info);
if (sub.type === 'http') {
var refresh = document.createElement('button');
refresh.className = 'icon-btn overwrite-refresh-btn';
refresh.type = 'button';
refresh.title = '<%:Refresh Subscription%>';
refresh.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.13-3.36L23 10"/><path d="M1 14l4.35 4.35A9 9 0 0 0 20.49 15"/></svg>';
refresh.onclick = function(e) {
e.stopPropagation();
var statusText = document.getElementById('config-editor-status-text');
if (statusText) {
statusText.textContent = '<%:Refreshing subscription...%>';
}
refresh.disabled = true;
var formData = new FormData();
formData.append('filename', name);
formData.append('type', sub.type || 'http');
formData.append('url', sub.url || '');
formData.append('update_days', sub.update_days || '');
formData.append('update_hour', sub.update_hour || '');
formData.append('order', (typeof sub.order !== 'undefined' && sub.order !== null && sub.order !== '') ? sub.order : idx);
formData.append('param', sub.param || '');
formData.append('config', self.parseOverwriteConfigValue(sub.config).join('\n'));
formData.append('enable', typeof sub.enable !== 'undefined' ? sub.enable : 1);
fetch('/cgi-bin/luci/admin/services/openclash/overwrite_subscribe_info', {
method: 'POST',
body: formData
}).then(r=>r.json()).then(function(data){
refresh.disabled = false;
if (statusText) {
if (data.status === 'success') {
statusText.textContent = '<%:Subscription refreshed successfully%>';
} else {
statusText.textContent = '<%:Subscription refresh failed%>: ' + (data.message || '');
}
}
setTimeout(function(){
if (statusText && (statusText.textContent.startsWith('<%:Subscription refreshed successfully%>') || statusText.textContent.startsWith('<%:Subscription refresh failed%>'))) {
statusText.textContent = '<%:Ready%>';
}
}, 2000);
}).catch(function(){
refresh.disabled = false;
if (statusText) statusText.textContent = '<%:Subscription refresh failed%>';
setTimeout(function(){
if (statusText && statusText.textContent.startsWith('<%:Subscription refresh failed%>')) {
statusText.textContent = '<%:Ready%>';
}
}, 2000);
});
setTimeout(function(){
self.loadConfigContent();
}, 1000);
};
card.appendChild(refresh);
}
var gear = document.createElement('button');
gear.className = 'icon-btn overwrite-gear-btn';
gear.type = 'button';
gear.title = '<%:Edit Module Info%>';
gear.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.09a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h.09a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.09a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>';
gear.onclick = function(e) {
e.stopPropagation();
ConfigEditor.showOverwriteSubmodel(name, sub, file.path);
};
card.appendChild(gear);
var del = document.createElement('button');
del.className = 'icon-btn overwrite-del-btn';
del.type = 'button';
del.title = '<%:Delete%>';
del.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/></svg>';
del.onclick = function(e) {
e.stopPropagation();
if (confirm('<%:Are you sure you want to delete this file and its subscription info?%>')) {
fetch('/cgi-bin/luci/admin/services/openclash/delete_overwrite_file', {
method: 'POST',
body: new URLSearchParams({ filename: name })
})
.then(r => r.json())
.then(function(data) {
if (data.status === 'success') {
self.loadOverwriteFiles();
setTimeout(function() {
var files = self.overwriteFiles.filter(f => f.name !== name && f.name !== 'openclash_custom_overwrite.sh');
if (files.length > 0) {
self.currentConfigFile = files[0].path;
self.loadConfigContent();
self.renderOverwriteCards();
var configNameElement = document.getElementById('config-file-name');
if (configNameElement) {
configNameElement.textContent = self.formatDisplayName(files[0].path);
}
} else {
self.currentConfigFile = '/etc/openclash/custom/openclash_custom_overwrite.sh';
self.loadConfigContent();
self.renderOverwriteCards();
var configNameElement = document.getElementById('config-file-name');
if (configNameElement) {
configNameElement.textContent = self.formatDisplayName(self.currentConfigFile);
}
}
}, 300);
} else {
statusText.textContent = '<%:Delete failed%>: ' + (data.message || '');
}
});
}
};
card.appendChild(del);
card.onclick = function(e) {
if (self.currentConfigFile !== file.path) {
self.currentConfigFile = file.path;
self.loadConfigContent();
self.renderOverwriteCards();
var configNameElement = document.getElementById('config-file-name');
if (configNameElement) {
configNameElement.textContent = self.formatDisplayName(file.path);
}
}
};
card.draggable = true;
card.addEventListener('dragstart', function(e) {
self.overwriteDrag.dragging = card;
self.overwriteDrag.startIndex = idx;
card.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
card.addEventListener('dragend', function(e) {
card.classList.remove('dragging');
self.overwriteDrag.dragging = null;
self.overwriteDrag.startIndex = null;
list.querySelectorAll('.overwrite-drag-line').forEach(line => line.remove());
});
card.addEventListener('dragover', function(e) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
var rect = card.getBoundingClientRect();
var listRect = list.getBoundingClientRect();
var cardIndex = parseInt(card.dataset.index);
var mid = rect.left + rect.width * 0.5;
let insertPos = null;
if (e.clientX < mid) {
insertPos = 'before';
} else if (e.clientX >= mid) {
insertPos = 'after';
}
let currentLine = list.querySelector('.overwrite-drag-line');
if (!currentLine && insertPos) {
var line = document.createElement('div');
line.className = 'overwrite-drag-line';
if (insertPos === 'before') {
list.insertBefore(line, card);
} else if (insertPos === 'after') {
if (card.nextSibling) {
list.insertBefore(line, card.nextSibling);
} else {
list.appendChild(line);
}
}
}
var scrollZone = 40;
if (e.clientX - listRect.left < scrollZone) {
list.scrollLeft -= 5;
} else if (listRect.right - e.clientX < scrollZone) {
list.scrollLeft += 5;
}
});
card.addEventListener('drop', function(e) {
e.preventDefault();
var dragging = self.overwriteDrag.dragging;
if (!dragging || dragging === card) return;
var from = parseInt(dragging.dataset.index);
var to = parseInt(card.dataset.index);
var rect = card.getBoundingClientRect();
var midX = rect.left + rect.width / 2;
if (e.clientX < midX) {
} else {
to++;
}
if (from === to || from + 1 === to) {
list.querySelectorAll('.overwrite-drag-line').forEach(line => line.remove());
self.saveOverwriteSort();
return;
}
var arr = files;
var moved = arr.splice(from, 1)[0];
arr.splice(to > from ? to - 1 : to, 0, moved);
var newArr = [];
if (customFile) newArr.push(customFile);
newArr = newArr.concat(arr);
self.overwriteFiles = newArr;
self.renderOverwriteCards();
self.saveOverwriteSort();
list.querySelectorAll('.overwrite-drag-line').forEach(line => line.remove());
});
card.addEventListener('touchstart', function(e) {
if (card.dataset.index === 'custom') return;
if (e.touches.length !== 1) return;
var target = e.target;
var isButton = target.closest('.overwrite-refresh-btn, .overwrite-gear-btn, .overwrite-del-btn, .oc-switch');
if (isButton) return;
self.overwriteDrag.touchDraggingCard = card;
self.overwriteDrag.touchDraggingMoved = false;
self.overwriteDrag.isTouchDragging = false;
self.overwriteDrag.startTouch = {
clientX: e.touches[0].clientX,
clientY: e.touches[0].clientY
};
self.overwriteDrag.touchTimer = setTimeout(function() {
if (!self.overwriteDrag.touchDraggingMoved && self.overwriteDrag.touchDraggingCard === card) {
self.overwriteDrag.isTouchDragging = true;
self.overwriteDrag.dragging = card;
self.overwriteDrag.startIndex = parseInt(card.dataset.index);
card.classList.add('dragging');
if (navigator.vibrate) {
navigator.vibrate(50);
}
}
}, 500);
});
card.addEventListener('touchmove', function(e) {
if (card.dataset.index === 'custom') return;
if (e.touches.length !== 1) return;
var touch = e.touches[0];
if (!self.overwriteDrag.touchDraggingMoved && self.overwriteDrag.startTouch) {
var dx = Math.abs(touch.clientX - self.overwriteDrag.startTouch.clientX);
var dy = Math.abs(touch.clientY - self.overwriteDrag.startTouch.clientY);
if (dx > 15 || dy > 15) {
self.overwriteDrag.touchDraggingMoved = true;
}
}
if (self.overwriteDrag.isTouchDragging) {
list.querySelectorAll('.overwrite-drag-line').forEach(line => line.remove());
var elementBelow = document.elementFromPoint(touch.clientX, touch.clientY);
var targetCard = null;
if (elementBelow) {
targetCard = elementBelow.closest('.sub-card.overwrite-item');
}
if (targetCard && targetCard !== card && targetCard.dataset.index !== 'custom') {
var rect = targetCard.getBoundingClientRect();
var midX = rect.left + rect.width / 2;
var line = document.createElement('div');
line.className = 'overwrite-drag-line';
if (touch.clientX < midX) {
list.insertBefore(line, targetCard);
} else {
if (targetCard.nextSibling) {
list.insertBefore(line, targetCard.nextSibling);
} else {
list.appendChild(line);
}
}
}
var listRect = list.getBoundingClientRect();
var scrollZone = 60;
if (touch.clientX - listRect.left < scrollZone && list.scrollLeft > 0) {
list.scrollLeft -= 10;
} else if (listRect.right - touch.clientX < scrollZone) {
list.scrollLeft += 10;
}
e.preventDefault();
}
});
card.addEventListener('touchend', function(e) {
if (self.overwriteDrag.touchTimer) {
clearTimeout(self.overwriteDrag.touchTimer);
self.overwriteDrag.touchTimer = null;
}
var wasInDraggingMode = self.overwriteDrag.isTouchDragging;
if (wasInDraggingMode) {
card.classList.remove('dragging');
var line = list.querySelector('.overwrite-drag-line');
if (line) {
var from = parseInt(card.dataset.index);
var allCards = Array.from(list.querySelectorAll('.sub-card.overwrite-item'));
var fileCards = allCards.filter(c => c.dataset && typeof c.dataset.index !== 'undefined' && c.dataset.index !== 'custom');
var lineRect = line.getBoundingClientRect();
var lineCenterX = lineRect.left + lineRect.width / 2;
var to = fileCards.length;
for (var i = 0; i < fileCards.length; i++) {
var rect = fileCards[i].getBoundingClientRect();
var cardMid = rect.left + rect.width / 2;
if (lineCenterX < cardMid) {
to = i;
break;
}
}
if (!(from === to || from + 1 === to)) {
var arr = self.overwriteFiles.filter(function(f){ return f.name !== 'openclash_custom_overwrite.sh'; });
var customFile = self.overwriteFiles.find(function(f){ return f.name === 'openclash_custom_overwrite.sh'; });
var moved = arr.splice(from, 1)[0];
arr.splice(to > from ? to - 1 : to, 0, moved);
var newArr = [];
if (customFile) newArr.push(customFile);
newArr = newArr.concat(arr);
self.overwriteFiles = newArr;
self.renderOverwriteCards();
self.saveOverwriteSort();
}
}
list.querySelectorAll('.overwrite-drag-line').forEach(function(l){ l.remove(); });
self.overwriteDrag.dragging = null;
self.overwriteDrag.startIndex = null;
self.overwriteDrag.isTouchDragging = false;
if (self.overwriteDrag.touchDraggingMoved) {
e.preventDefault();
e.stopPropagation();
}
}
self.overwriteDrag.touchDraggingMoved = false;
self.overwriteDrag.touchDraggingCard = null;
self.overwriteDrag.startTouch = null;
});
card.addEventListener('touchcancel', function() {
if (self.overwriteDrag.touchTimer) {
clearTimeout(self.overwriteDrag.touchTimer);
self.overwriteDrag.touchTimer = null;
}
if (self.overwriteDrag.isTouchDragging) {
card.classList.remove('dragging');
list.querySelectorAll('.overwrite-drag-line').forEach(line => line.remove());
self.overwriteDrag.dragging = null;
self.overwriteDrag.startIndex = null;
self.saveOverwriteSort();
}
self.overwriteDrag.isTouchDragging = false;
self.overwriteDrag.touchDraggingMoved = false;
self.overwriteDrag.touchDraggingCard = null;
self.overwriteDrag.startTouch = null;
});
list.appendChild(card);
});
list.addEventListener('dragover', function(e) {
e.preventDefault();
const cards = Array.from(list.querySelectorAll('.sub-card.overwrite-item'));
if (cards.length === 0) return;
const realCards = cards.slice(2);
if (realCards.length === 0) return;
const x = e.clientX;
let insertBefore = null;
for (let i = 0; i < realCards.length; i++) {
const rect = realCards[i].getBoundingClientRect();
if (x < rect.left + rect.width / 2) {
insertBefore = realCards[i];
break;
}
}
const line = document.createElement('div');
line.className = 'overwrite-drag-line';
if (insertBefore) {
list.insertBefore(line, insertBefore);
} else {
list.appendChild(line);
}
const lines = Array.from(list.querySelectorAll('.overwrite-drag-line'));
lines.forEach(l => {
if (l !== line) l.remove();
});
});
list.addEventListener('dragleave', function(e) {
if (!list.contains(e.relatedTarget)) {
list.querySelectorAll('.overwrite-drag-line').forEach(line => line.remove());
}
});
list.addEventListener('drop', function(e) {
e.preventDefault();
const dragging = ConfigEditor.overwriteDrag.dragging;
if (!dragging) return;
const from = parseInt(dragging.dataset.index);
const lines = Array.from(list.querySelectorAll('.overwrite-drag-line'));
if (lines.length === 0) return;
const line = lines[0];
const allCards = Array.from(list.querySelectorAll('.sub-card.overwrite-item'));
const fileCards = allCards.filter(c => typeof c.dataset.index !== 'undefined' && c.dataset.index !== 'custom');
let nextNode = line.nextSibling;
while (nextNode && !(nextNode.classList && nextNode.classList.contains('sub-card') && typeof nextNode.dataset.index !== 'undefined' && nextNode.dataset.index !== 'custom')) {
nextNode = nextNode.nextSibling;
}
let to = fileCards.indexOf(nextNode);
if (to === -1) to = fileCards.length;
if (from === to || from + 1 === to) {
list.querySelectorAll('.overwrite-drag-line').forEach(line => line.remove());
ConfigEditor.saveOverwriteSort();
return;
}
const arr = ConfigEditor.overwriteFiles.filter((f, i) => f.name !== 'openclash_custom_overwrite.sh');
const customFile = ConfigEditor.overwriteFiles.find(f => f.name === 'openclash_custom_overwrite.sh');
const moved = arr.splice(from, 1)[0];
arr.splice(to > from ? to - 1 : to, 0, moved);
let newArr = [];
if (customFile) newArr.push(customFile);
newArr = newArr.concat(arr);
ConfigEditor.overwriteFiles = newArr;
ConfigEditor.renderOverwriteCards();
ConfigEditor.saveOverwriteSort();
list.querySelectorAll('.overwrite-drag-line').forEach(line => line.remove());
});
list.addEventListener('touchmove', function(e) {
if (!ConfigEditor.overwriteDrag.isTouchDragging) return;
const touch = e.touches[0];
const cards = Array.from(list.querySelectorAll('.sub-card.overwrite-item'));
if (cards.length === 0) return;
const realCards = cards.slice(2);
if (realCards.length === 0) return;
let insertBefore = null;
for (let i = 0; i < realCards.length; i++) {
const rect = realCards[i].getBoundingClientRect();
if (touch.clientX < rect.left + rect.width / 2) {
insertBefore = realCards[i];
break;
}
}
list.querySelectorAll('.overwrite-drag-line').forEach(line => line.remove());
if (insertBefore) {
const line = document.createElement('div');
line.className = 'overwrite-drag-line';
list.insertBefore(line, insertBefore);
} else {
const line = document.createElement('div');
line.className = 'overwrite-drag-line';
list.appendChild(line);
}
});
list.addEventListener('touchend', function(e) {
var dragging = ConfigEditor.overwriteDrag.dragging;
if (!dragging) {
list.querySelectorAll('.overwrite-drag-line').forEach(function(line){ line.remove(); });
return;
}
var from = parseInt(dragging.dataset.index);
var line = list.querySelector('.overwrite-drag-line');
if (!line) {
ConfigEditor.overwriteDrag.dragging = null;
ConfigEditor.overwriteDrag.startIndex = null;
ConfigEditor.overwriteDrag.isTouchDragging = false;
return;
}
var allCards = Array.from(list.querySelectorAll('.sub-card.overwrite-item'));
var fileCards = allCards.filter(function(c){ return c.dataset && typeof c.dataset.index !== 'undefined' && c.dataset.index !== 'custom'; });
var lineRect = line.getBoundingClientRect();
var lineCenterX = lineRect.left + lineRect.width / 2;
var to = fileCards.length;
for (var i = 0; i < fileCards.length; i++) {
var rect = fileCards[i].getBoundingClientRect();
var cardMid = rect.left + rect.width / 2;
if (lineCenterX < cardMid) {
to = i;
break;
}
}
if (!(from === to || from + 1 === to)) {
var arr = ConfigEditor.overwriteFiles.filter(function(f){ return f.name !== 'openclash_custom_overwrite.sh'; });
var customFile = ConfigEditor.overwriteFiles.find(function(f){ return f.name === 'openclash_custom_overwrite.sh'; });
var moved = arr.splice(from, 1)[0];
arr.splice(to > from ? to - 1 : to, 0, moved);
var newArr = [];
if (customFile) newArr.push(customFile);
newArr = newArr.concat(arr);
ConfigEditor.overwriteFiles = newArr;
ConfigEditor.renderOverwriteCards();
ConfigEditor.saveOverwriteSort();
} else {
ConfigEditor.saveOverwriteSort();
}
list.querySelectorAll('.overwrite-drag-line').forEach(function(l){ l.remove(); });
ConfigEditor.overwriteDrag.dragging = null;
ConfigEditor.overwriteDrag.startIndex = null;
ConfigEditor.overwriteDrag.isTouchDragging = false;
});
bar.style.display = self.isOverwrite ? 'block' : 'none';
},
saveOverwriteSort: function() {
var self = this;
var reqs = [];
this.overwriteFiles.forEach(function(file, idx) {
var name = file.name || (file.path ? file.path.split('/').pop() : '');
if (name === 'openclash_custom_overwrite.sh') return;
var sub = self.overwriteSubInfo[name] || {};
var formData = new FormData();
formData.append('filename', name);
formData.append('type', sub.type || 'file');
formData.append('url', sub.url || '');
formData.append('update_days', sub.update_days || '');
formData.append('update_hour', sub.update_hour || '');
formData.append('param', sub.param || '');
formData.append('config', self.parseOverwriteConfigValue(sub.config).join('\n'));
formData.append('order', idx);
reqs.push(fetch('/cgi-bin/luci/admin/services/openclash/overwrite_subscribe_info', {
method: 'POST',
body: formData
}));
});
Promise.all(reqs).then(function() {
self.loadOverwriteSubInfo();
});
},
getOverwriteConfigFiles: function() {
var rawList = [];
if (window.configFiles && Array.isArray(window.configFiles)) {
rawList = window.configFiles;
} else if (window.ConfigFileManager && Array.isArray(window.ConfigFileManager.configList)) {
rawList = window.ConfigFileManager.configList;
}
return rawList.map(function(file) {
if (typeof file === 'string') {
return {
name: file,
path: file
};
}
return {
name: file.name || file.filename || file.path || '',
path: file.path || file.filepath || file.name || ''
};
}).filter(function(file) {
return !!file.path;
});
},
parseOverwriteConfigValue: function(value) {
if (!value) return [];
if (Array.isArray(value)) {
return value.filter(function(item) { return !!item; });
}
return String(value).split(/[\r\n,;]+/).map(function(item) {
return item.trim();
}).filter(function(item) {
return !!item;
});
},
renderOverwriteConfigDropdown: function(configValue, dropdownId) {
var files = this.getOverwriteConfigFiles();
var selected = this.parseOverwriteConfigValue(configValue);
var selectedMap = {};
selected.forEach(function(item) {
selectedMap[item] = true;
});
if (!selected.length) {
selectedMap['all'] = true;
}
var allChecked = !!selectedMap['all'];
var allOptionHtml = `
<label class="overwrite-config-option${allChecked ? ' selected' : ''}" data-path="all" data-all-option="1">
<span class="overwrite-config-option-left">
<input type="checkbox" value="all"${allChecked ? ' checked' : ''}>
<span class="overwrite-config-option-name" title="<%:Use For All Config File%>"><%:Use For All Config File%></span>
</span>
<span class="overwrite-config-option-state">${allChecked ? '✓' : ''}</span>
</label>
`;
var fileOptionsHtml = files.length ? files.map(function(file) {
var checked = (!allChecked && (selectedMap[file.path] || selectedMap[file.name])) ? ' checked' : '';
var selectedClass = checked ? ' selected' : '';
var disabledClass = allChecked ? ' disabled-by-all' : '';
var stateText = checked ? '✓' : '';
var disabled = allChecked;
return `
<label class="overwrite-config-option${selectedClass}${disabledClass}" data-path="${file.path}">
<span class="overwrite-config-option-left">
<input type="checkbox" value="${file.path}"${checked}${disabled ? ' disabled' : ''}>
<span class="overwrite-config-option-name" title="${file.name}">${file.name}</span>
</span>
<span class="overwrite-config-option-state">${stateText}</span>
</label>
`;
}).join('') : '<div class="overwrite-config-option"><span class="overwrite-config-option-name"><%:No config files found%></span></div>';
var optionsHtml = allOptionHtml + fileOptionsHtml;
return `
<div class="overwrite-config-dropdown form-select-wrapper" id="${dropdownId}">
<button type="button" class="overwrite-config-dropdown-btn form-select">
<span class="overwrite-config-dropdown-text"><%:Use For All Config File%></span>
<span class="overwrite-config-dropdown-arrow"></span>
</button>
<div class="overwrite-config-dropdown-panel">
${optionsHtml}
</div>
</div>
`;
},
updateOverwriteConfigDropdownLabel: function(dropdown) {
if (!dropdown) return;
var textEl = dropdown.querySelector('.overwrite-config-dropdown-text');
if (!textEl) return;
var checked = dropdown.querySelectorAll('.overwrite-config-option input[type="checkbox"]:checked');
if (!checked.length) {
textEl.textContent = '<%:Use For All Config File%>';
return;
}
if (checked.length === 1) {
var oneName = checked[0].closest('.overwrite-config-option').querySelector('.overwrite-config-option-name');
textEl.textContent = oneName ? oneName.textContent : '<%:1 file selected%>';
return;
}
textEl.textContent = checked.length + ' <%:files selected%>';
},
bindOverwriteConfigDropdown: function(container) {
if (!container || container.dataset.inited === '1') return;
container.dataset.inited = '1';
var self = this;
var btn = container.querySelector('.overwrite-config-dropdown-btn');
var panel = container.querySelector('.overwrite-config-dropdown-panel');
function syncAllExclusiveState() {
var allInput = container.querySelector('.overwrite-config-option input[type="checkbox"][value="all"]');
var allOption = allInput ? allInput.closest('.overwrite-config-option') : null;
var allState = allOption ? allOption.querySelector('.overwrite-config-option-state') : null;
var allSelected = !!(allInput && allInput.checked);
if (allOption) {
allOption.classList.toggle('selected', allSelected);
}
if (allState) {
allState.textContent = allSelected ? '✓' : '';
}
container.querySelectorAll('.overwrite-config-option input[type="checkbox"]').forEach(function(input) {
if (input.value === 'all') return;
var option = input.closest('.overwrite-config-option');
var state = option ? option.querySelector('.overwrite-config-option-state') : null;
if (allSelected) {
input.checked = false;
}
input.disabled = allSelected;
if (option) {
option.classList.toggle('selected', input.checked);
option.classList.toggle('disabled-by-all', allSelected);
option.setAttribute('aria-disabled', allSelected ? 'true' : 'false');
}
if (state) {
state.textContent = input.checked ? '✓' : '';
}
});
}
this.updateOverwriteConfigDropdownLabel(container);
syncAllExclusiveState();
this.updateOverwriteConfigDropdownLabel(container);
if (btn && !btn.disabled) {
btn.addEventListener('click', function(e) {
e.stopPropagation();
container.classList.toggle('open');
});
}
container.querySelectorAll('.overwrite-config-option input[type="checkbox"]').forEach(function(input) {
input.addEventListener('change', function() {
if (input.value === 'all') {
syncAllExclusiveState();
} else if (input.checked) {
var allInput = container.querySelector('.overwrite-config-option input[type="checkbox"][value="all"]');
if (allInput) {
allInput.checked = false;
}
syncAllExclusiveState();
}
var option = input.closest('.overwrite-config-option');
var state = option ? option.querySelector('.overwrite-config-option-state') : null;
if (option) {
option.classList.toggle('selected', input.checked);
}
if (state) {
state.textContent = input.checked ? '✓' : '';
}
self.updateOverwriteConfigDropdownLabel(container);
});
});
if (panel) {
panel.addEventListener('click', function(e) {
e.stopPropagation();
});
}
document.addEventListener('click', function(e) {
if (!container.contains(e.target)) {
container.classList.remove('open');
}
});
},
getOverwriteConfigSelection: function(root, dropdownId) {
var dropdown = root.querySelector('#' + dropdownId);
if (!dropdown) return [];
var selected = Array.from(dropdown.querySelectorAll('.overwrite-config-option input[type="checkbox"]:checked')).map(function(input) {
return input.value;
}).filter(function(value) {
return !!value;
});
if (selected.indexOf('all') !== -1) {
return ['all'];
}
return selected;
},
renderOverwriteSubForm: function(options) {
var name = options.name || '';
var sub = options.sub || {};
var readonly = !!options.readonly;
var showTabs = !!options.showTabs;
var activeTab = options.activeTab || 'file';
var fileConfigDropdown = this.renderOverwriteConfigDropdown(sub.config || '', 'overwrite-upload-config-dropdown');
var subscribeConfigDropdown = this.renderOverwriteConfigDropdown(sub.config || '', 'overwrite-subscribe-config-dropdown');
var tabsHtml = showTabs ? `
<div class="upload-mode-selector">
<div class="mode-tabs">
<button type="button" class="mode-tab${activeTab==='file'?' active':''}" id="overwrite-upload-mode-file" data-mode="file">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17,11 12,6 7,11"></polyline>
<line x1="12" y1="18" x2="12" y2="6"></line>
</svg>
<%:Upload File%>
</button>
<button type="button" class="mode-tab${activeTab==='subscribe'?' active':''}" id="overwrite-upload-mode-subscribe" data-mode="subscribe">
<svg width="15" height="15" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M26.2401 16.373L17.1001 7.23303C14.4388 4.57168 10.0653 4.6303 7.33158 7.36397C4.59791 10.0976 4.53929 14.4712 7.20064 17.1325L15.1359 25.0678" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M32.9027 23.0031L40.838 30.9384C43.4994 33.5998 43.4407 37.9733 40.7071 40.707C37.9734 43.4407 33.5999 43.4993 30.9385 40.8379L21.7985 31.6979" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M26.1093 26.1416C28.843 23.4079 28.9016 19.0344 26.2403 16.373" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M21.7989 21.7984C19.0652 24.5321 19.0066 28.9056 21.6679 31.5669" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
<%:Subscribe Link%>
</button>
</div>
</div>
` : '';
var fileContent = `
<form id="overwrite-upload-form-file" style="display:${activeTab==='file'?'block':'none'};">
<div class="form-group" style="margin-bottom:20px;">
<label for="overwrite-upload-config"><%:Config File%>:</label>
${fileConfigDropdown}
</div>
<div class="upload-zone" id="overwrite-upload-zone">
<div class="upload-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17,11 12,6 7,11"></polyline>
<line x1="12" y1="18" x2="12" y2="6"></line>
</svg>
</div>
<div class="upload-text">
<p class="upload-primary"><%:Click to select file or drag and drop%></p>
<p class="upload-secondary"><%:Support txt,conf files, max size 10MB%></p>
</div>
<input type="file" id="overwrite-upload-file-input" accept=".txt,.conf,*" style="display: none;">
</div>
<div class="filename-input-container" style="margin-top:20px;">
<label for="overwrite-upload-filename-input"><%:File Name%>:</label>
<input type="text" id="overwrite-upload-filename-input" placeholder="<%:Please enter a filename%>" class="form-input" value="${name}" ${readonly ? 'readonly' : ''}/>
</div>
</form>
`;
var subscribeContent = `
<form id="overwrite-upload-form-subscribe" class="subscribe-form">
<div class="form-group">
<label><%:File Name%>:</label>
<input type="text" class="form-input" name="filename" id="overwrite-subscribe-filename" value="${name}" ${readonly ? 'readonly' : ''} placeholder="<%:Please enter a filename%>">
</div>
<div class="form-group">
<label for="overwrite-subscribe-config"><%:Config File%>:</label>
${subscribeConfigDropdown}
</div>
<div class="form-group">
<label><%:Type%>:</label>
<div class="form-select-wrapper">
<select class="form-select" name="type" id="overwrite-subscribe-type" ${readonly ? 'disabled' : ''}>
<option value="file" ${(sub.type === 'file' || !sub.type) ? 'selected' : ''}>file</option>
<option value="http" ${sub.type === 'http' ? 'selected' : ''}>http</option>
</select>
</div>
</div>
<div class="form-group" id="overwrite-subscribe-url-group" style="display:${sub.type === 'http' ? 'block' : 'none'};">
<label><%:Subscription URL%>:</label>
<input type="text" class="form-input" name="url" id="overwrite-subscribe-url" value="${sub.url||''}">
</div>
<div class="form-group" id="overwrite-subscribe-update-group" style="display:${sub.type === 'http' ? 'block' : 'none'};">
<label><%:Update Time%>:</label>
<div class="update-row">
<div class="form-select-wrapper">
<select class="form-select" name="update_days" id="overwrite-subscribe-update-days">
<option value="off" ${sub.update_days==='off'?'selected':''}><%:OFF%></option>
<option value="*" ${sub.update_days==='*'?'selected':''}><%:Every Day%></option>
<option value="1" ${sub.update_days==='1'?'selected':''}><%:Every Monday%></option>
<option value="2" ${sub.update_days==='2'?'selected':''}><%:Every Tuesday%></option>
<option value="3" ${sub.update_days==='3'?'selected':''}><%:Every Wednesday%></option>
<option value="4" ${sub.update_days==='4'?'selected':''}><%:Every Thursday%></option>
<option value="5" ${sub.update_days==='5'?'selected':''}><%:Every Friday%></option>
<option value="6" ${sub.update_days==='6'?'selected':''}><%:Every Saturday%></option>
<option value="0" ${sub.update_days==='0'?'selected':''}><%:Every Sunday%></option>
</select>
</div>
<div class="form-select-wrapper">
<select class="form-select" name="update_hour" id="overwrite-subscribe-update-hour">
<option value="off" ${sub.update_hour==='off'?'selected':''}><%:OFF%></option>
${[...Array(24).keys()].map(h=>`<option value="${h}" ${sub.update_hour==h?'selected':''}>${h}:00</option>`).join('')}
</select>
</div>
</div>
</div>
<div class="form-group" id="overwrite-subscribe-param-group" style="display:'block';">
<label><%:Environment variable%>:</label>
<input type="text" class="form-input" name="param" id="overwrite-subscribe-param" value="${sub.param||''}" placeholder="key1=value1;key2=value2">
</div>
<div class="upload-progress" id="overwrite-upload-progress" style="display:none;">
<div class="progress-bar">
<div class="progress-fill" id="overwrite-progress-fill" style="width:0%;"></div>
</div>
<div class="progress-text" id="overwrite-progress-text">0%</div>
</div>
</form>
`;
return `
${tabsHtml}
<div class="config-upload-content" id="overwrite-upload-content-file" style="${activeTab==='file'?'':'display:none;'}">
${fileContent}
</div>
<div class="config-upload-content" id="overwrite-upload-content-subscribe" style="${activeTab==='subscribe'?'':'display:none;'}">
${subscribeContent}
</div>
`;
},
showAddOverwritemodel: function() {
var self = this;
if (document.getElementById('overwrite-add-model')) return;
var ocDiv = document.createElement('div');
ocDiv.className = 'oc';
var mainOc = document.querySelector('.oc[data-darkmode="true"]');
if (mainOc) {
ocDiv.setAttribute('data-darkmode', 'true');
}
var overlay = document.createElement('div');
overlay.className = 'config-upload-model-overlay show';
overlay.style.zIndex = 10001;
overlay.id = 'overwrite-add-model';
var model = document.createElement('div');
model.className = 'config-upload-model';
model.innerHTML = `
<div class="config-upload-header">
<div class="config-upload-title">
<span><%:Add Overwrite Module%></span>
</div>
<div class="config-upload-actions">
<button type="button" class="icon-btn" id="overwrite-add-close" title="<%:Close%>">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
<div class="config-upload-content">
${self.renderOverwriteSubForm({name:'', sub:{}, readonly:false, showTabs:true, activeTab:'file'})}
</div>
<div class="config-upload-footer">
<div class="config-upload-status">
<span id="overwrite-upload-status-text"><%:Ready to add file%></span>
</div>
<div class="config-upload-buttons">
<button type="button" class="btn cancel-btn" id="overwrite-upload-cancel"><%:Cancel%></button>
<button type="button" class="btn upload-btn" id="overwrite-upload-submit"><%:Add%></button>
</div>
</div>
`;
overlay.appendChild(model);
ocDiv.appendChild(overlay);
document.body.appendChild(ocDiv);
this.bindOverwriteConfigDropdown(model.querySelector('#overwrite-upload-config-dropdown'));
this.bindOverwriteConfigDropdown(model.querySelector('#overwrite-subscribe-config-dropdown'));
var tabFile = model.querySelector('#overwrite-upload-mode-file');
var tabSub = model.querySelector('#overwrite-upload-mode-subscribe');
var contentFile = model.querySelector('#overwrite-upload-content-file');
var contentSub = model.querySelector('#overwrite-upload-content-subscribe');
tabFile.onclick = function() {
tabFile.classList.add('active');
tabSub.classList.remove('active');
contentFile.style.display = 'block';
contentSub.style.display = 'none';
};
tabSub.onclick = function() {
tabFile.classList.remove('active');
tabSub.classList.add('active');
contentFile.style.display = 'none';
contentSub.style.display = 'block';
};
var typeSelect = model.querySelector('#overwrite-subscribe-type');
var urlGroup = model.querySelector('#overwrite-subscribe-url-group');
var updateGroup = model.querySelector('#overwrite-subscribe-update-group');
if (typeSelect) {
if (typeSelect.value === 'http') {
urlGroup.style.display = 'block';
updateGroup.style.display = 'block';
} else {
urlGroup.style.display = 'none';
updateGroup.style.display = 'none';
}
typeSelect.addEventListener('change', function() {
if (typeSelect.value === 'http') {
urlGroup.style.display = 'block';
updateGroup.style.display = 'block';
} else {
urlGroup.style.display = 'none';
updateGroup.style.display = 'none';
}
});
}
var urlInput = model.querySelector('#overwrite-subscribe-url');
var filenameInputSub = model.querySelector('#overwrite-subscribe-filename');
if (urlInput && filenameInputSub) {
urlInput.addEventListener('input', function() {
if (!filenameInputSub.value.trim() && urlInput.value.trim()) {
try {
var url = urlInput.value.trim();
var name = url.split('?')[0].split('/').pop();
if (name && /^[\w\.\-\_]+$/.test(name)) {
filenameInputSub.value = name;
}
} catch (e) {}
}
});
}
model.querySelector('#overwrite-add-close').onclick = function() {
document.body.removeChild(ocDiv);
};
overlay.onclick = function(e) {
if (e.target === overlay) {
document.body.removeChild(ocDiv);
}
};
model.querySelector('#overwrite-upload-cancel').onclick = function() {
document.body.removeChild(ocDiv);
};
var uploadZone = model.querySelector('#overwrite-upload-zone');
var fileInput = model.querySelector('#overwrite-upload-file-input');
var filenameInput = model.querySelector('#overwrite-upload-filename-input');
var statusText = model.querySelector('#overwrite-upload-status-text');
var selectedFile = null;
uploadZone.onclick = function() {
fileInput.click();
};
fileInput.onchange = function(e) {
if (e.target.files.length > 0) {
selectedFile = e.target.files[0];
uploadZone.classList.add('has-file');
uploadZone.querySelector('.upload-primary').textContent = '<%:File selected:%> ' + selectedFile.name;
uploadZone.querySelector('.upload-secondary').textContent = '<%:Size:%> ' + (selectedFile.size/1024/1024).toFixed(2) + ' MB';
filenameInput.value = selectedFile.name;
}
};
var submitBtn = model.querySelector('#overwrite-upload-submit');
function validateAddOverwriteForm() {
if (tabFile.classList.contains('active')) {
const filename = filenameInput.value.trim();
submitBtn.disabled = !(selectedFile && filename && filename !== 'openclash_custom_overwrite.sh');
} else {
const filename = model.querySelector('#overwrite-subscribe-filename').value.trim();
const type = model.querySelector('#overwrite-subscribe-type').value;
const url = model.querySelector('#overwrite-subscribe-url').value.trim();
let valid = !!filename && filename !== 'openclash_custom_overwrite.sh';
if (type === 'http') {
valid = valid && !!url && /^https?:\/\/[^ \n|]+$/.test(url);
}
submitBtn.disabled = !valid;
}
}
validateAddOverwriteForm();
filenameInput.addEventListener('input', validateAddOverwriteForm);
if (model.querySelector('#overwrite-subscribe-filename')) {
model.querySelector('#overwrite-subscribe-filename').addEventListener('input', validateAddOverwriteForm);
}
if (model.querySelector('#overwrite-subscribe-type')) {
model.querySelector('#overwrite-subscribe-type').addEventListener('change', validateAddOverwriteForm);
}
if (model.querySelector('#overwrite-subscribe-url')) {
model.querySelector('#overwrite-subscribe-url').addEventListener('input', validateAddOverwriteForm);
}
tabFile.addEventListener('click', function() {
setTimeout(validateAddOverwriteForm, 0);
});
tabSub.addEventListener('click', function() {
setTimeout(validateAddOverwriteForm, 0);
});
fileInput.addEventListener('change', validateAddOverwriteForm);
model.querySelector('#overwrite-upload-submit').onclick = function() {
if (tabFile.classList.contains('active')) {
var filename = filenameInput.value.trim();
if (!filename) {
alert('<%:Please enter a filename%>');
return;
}
if (filename === 'openclash_custom_overwrite.sh') {
alert('<%:openclash_custom_overwrite.sh already exists and cannot be added again%>');
return;
}
if (selectedFile) {
var reader = new FileReader();
reader.onload = function(e) {
var fileContent = e.target.result;
var selectedConfigPaths = self.getOverwriteConfigSelection(model, 'overwrite-upload-config-dropdown');
var formData = new FormData();
formData.append('filename', filename);
formData.append('config_file', fileContent);
formData.append('order', self.overwriteFileCardCount + 1);
formData.append('enable', '0');
formData.append('config', selectedConfigPaths.join('\n'));
fetch('/cgi-bin/luci/admin/services/openclash/upload_overwrite', {
method: 'POST',
body: formData
}).then(r=>r.json()).then(function(data){
if (data.status === 'success') {
statusText.textContent = '<%:Upload successful%>';
document.body.removeChild(ocDiv);
self.loadOverwriteFiles();
setTimeout(function() {
self.currentConfigFile = '/etc/openclash/overwrite/' + filename;
self.isOverwrite = true;
self.showOverwrite(self.currentConfigFile);
}, 300);
} else {
statusText.textContent = '<%:Upload failed:%> ' + (data.message || '');
}
});
};
reader.onerror = function() {
statusText.textContent = '<%:Failed to read file%>';
};
reader.readAsText(selectedFile, 'UTF-8');
} else {
alert('<%:No Specify Upload File%>');
return;
}
} else {
var form = model.querySelector('#overwrite-upload-form-subscribe');
var filename = form.querySelector('#overwrite-subscribe-filename').value.trim();
var url = form.querySelector('#overwrite-subscribe-url').value.trim();
var update_days = form.querySelector('#overwrite-subscribe-update-days').value;
var update_hour = form.querySelector('#overwrite-subscribe-update-hour').value;
var type = form.querySelector('#overwrite-subscribe-type').value;
var param = form.querySelector('#overwrite-subscribe-param').value.trim();
var selectedConfigPaths = self.getOverwriteConfigSelection(model, 'overwrite-subscribe-config-dropdown');
if (!filename) {
alert('<%:Please enter a filename%>');
return;
}
if (filename === 'openclash_custom_overwrite.sh') {
alert('<%:openclash_custom_overwrite.sh already exists and cannot be added again%>');
return;
}
if (type === 'http' && !url) {
alert('<%:Please enter subscription URL%>');
return;
}
if (type === 'http' && !/^https?:\/\/[^ \n|]+$/.test(url)) {
alert('<%:Invalid subscription URL format, only single HTTP/HTTPS link is supported%>');
return;
}
var formData = new FormData();
formData.append('filename', filename);
formData.append('type', type);
formData.append('param', param);
formData.append('order', self.overwriteFileCardCount + 1);
formData.append('enable', '0');
formData.append('config', selectedConfigPaths.join('\n'));
if (type === 'http') {
formData.append('url', url);
formData.append('update_days', update_days);
formData.append('update_hour', update_hour);
}
var progressContainer = form.querySelector('#overwrite-upload-progress');
var progressFill = form.querySelector('#overwrite-progress-fill');
var progressText = form.querySelector('#overwrite-progress-text');
if (type === 'http') {
statusText.textContent = '<%:Downloading subscription content...%>';
progressContainer.style.display = 'block';
var progress = 0;
var interval = setInterval(function() {
progress = Math.min(progress + Math.random() * 20, 90);
progressFill.style.width = progress + '%';
progressText.textContent = '<%:Downloading...%> ' + Math.floor(progress) + '%';
}, 200);
}
fetch('/cgi-bin/luci/admin/services/openclash/overwrite_subscribe_info', {
method: 'POST',
body: formData
}).then(r=>r.json()).then(function(data){
if (type === 'http') {
clearInterval(interval);
if (data.status !== 'success') {
progressFill.style.width = '0%';
progressText.textContent = '<%:Download failed%> 0%';
} else {
progressFill.style.width = '100%';
progressText.textContent = '<%:Download completed%> 100%';
}
}
if (data.status === 'success') {
statusText.textContent = '<%:Subscription added successfully%>';
} else {
statusText.textContent = '<%:Failed to save subscription info:%> ' + (data.message || '');
}
setTimeout(function() {
document.body.removeChild(ocDiv);
self.loadOverwriteFiles();
setTimeout(function() {
self.currentConfigFile = '/etc/openclash/overwrite/' + filename;
self.isOverwrite = true;
self.showOverwrite(self.currentConfigFile);
}, 300);
}, 800);
});
}
};
},
showOverwriteSubmodel: function(name, sub, filePath) {
var self = this;
if (document.getElementById('overwrite-sub-model')) return;
var ocDiv = document.createElement('div');
ocDiv.className = 'oc';
var mainOc = document.querySelector('.oc[data-darkmode="true"]');
if (mainOc) {
ocDiv.setAttribute('data-darkmode', 'true');
}
var overlay = document.createElement('div');
overlay.className = 'config-upload-model-overlay show';
overlay.style.zIndex = 10001;
overlay.id = 'overwrite-sub-model';
var model = document.createElement('div');
model.className = 'config-upload-model';
model.innerHTML = `
<div class="config-upload-header">
<div class="config-upload-title">
<span><%:Overwrite Module Edit%></span>
</div>
<div class="config-upload-actions">
<button type="button" class="icon-btn" id="overwrite-sub-close" title="<%:Close%>">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
<div class="config-upload-content">
${self.renderOverwriteSubForm({name:name, sub:sub, readonly:false, showTabs:false, activeTab:'subscribe'})}
</div>
<div class="config-upload-footer">
<div class="config-upload-status">
<span id="overwrite-edit-status-text"><%:Ready to edit%></span>
</div>
<div class="config-upload-buttons">
<button type="button" class="btn cancel-btn" id="overwrite-edit-cancel"><%:Cancel%></button>
<button type="button" class="btn upload-btn" id="overwrite-edit-submit"><%:Save%></button>
</div>
</div>
`;
overlay.appendChild(model);
ocDiv.appendChild(overlay);
document.body.appendChild(ocDiv);
this.bindOverwriteConfigDropdown(model.querySelector('#overwrite-subscribe-config-dropdown'));
var typeSelect = model.querySelector('#overwrite-subscribe-type');
var urlGroup = model.querySelector('#overwrite-subscribe-url-group');
var updateGroup = model.querySelector('#overwrite-subscribe-update-group');
if (typeSelect) {
if (typeSelect.value === 'http') {
urlGroup.style.display = 'block';
updateGroup.style.display = 'block';
} else {
urlGroup.style.display = 'none';
updateGroup.style.display = 'none';
}
typeSelect.addEventListener('change', function() {
if (typeSelect.value === 'http') {
urlGroup.style.display = 'block';
updateGroup.style.display = 'block';
} else {
urlGroup.style.display = 'none';
updateGroup.style.display = 'none';
}
});
}
model.querySelector('#overwrite-sub-close').onclick = function() {
document.body.removeChild(ocDiv);
};
overlay.onclick = function(e) {
if (e.target === overlay) {
document.body.removeChild(ocDiv);
}
};
model.querySelector('#overwrite-edit-cancel').onclick = function() {
document.body.removeChild(ocDiv);
};
var submitBtn = model.querySelector('#overwrite-edit-submit');
function validateEditOverwriteForm() {
const form = model.querySelector('#overwrite-upload-form-subscribe');
const filename = form.querySelector('#overwrite-subscribe-filename').value.trim();
const type = form.querySelector('#overwrite-subscribe-type').value;
const url = form.querySelector('#overwrite-subscribe-url').value.trim();
let valid = !!filename && filename !== 'openclash_custom_overwrite.sh';
if (type === 'http') {
valid = valid && !!url && /^https?:\/\/[^ \n|]+$/.test(url);
}
submitBtn.disabled = !valid;
}
validateEditOverwriteForm();
model.querySelector('#overwrite-subscribe-filename').addEventListener('input', validateEditOverwriteForm);
model.querySelector('#overwrite-subscribe-type').addEventListener('change', validateEditOverwriteForm);
model.querySelector('#overwrite-subscribe-url').addEventListener('input', validateEditOverwriteForm);
model.querySelector('#overwrite-edit-submit').onclick = function() {
var form = model.querySelector('#overwrite-upload-form-subscribe');
var newName = form.querySelector('#overwrite-subscribe-filename').value.trim();
var type = form.querySelector('#overwrite-subscribe-type').value;
var url = form.querySelector('#overwrite-subscribe-url').value.trim();
var param = form.querySelector('#overwrite-subscribe-param').value.trim();
var selectedConfigPaths = self.getOverwriteConfigSelection(model, 'overwrite-subscribe-config-dropdown');
if (!newName) {
alert('<%:Please enter a filename%>');
return;
}
if (newName === 'openclash_custom_overwrite.sh') {
alert('<%:openclash_custom_overwrite.sh already exists and cannot be added again%>');
return;
}
if (type === 'http') {
if (!url) {
alert('<%:Please enter subscription URL%>');
return;
}
if (!/^https?:\/\/[^ \n|]+$/.test(url)) {
alert('<%:Invalid subscription URL format, only single HTTP/HTTPS link is supported%>');
return;
}
}
var formData = new FormData(form);
if (type !== 'http') {
formData.delete('url');
formData.delete('update_days');
formData.delete('update_hour');
}
formData.delete('config');
formData.append('config', selectedConfigPaths.join('\n'));
if (newName !== name) {
formData.append('old_filename', name);
}
if (typeof sub.enable !== 'undefined') {
formData.append('enable', sub.enable);
} else {
formData.append('enable', '0');
}
formData.append('order', sub.order || 1);
var progressContainer = form.querySelector('#overwrite-upload-progress');
var progressFill = form.querySelector('#overwrite-progress-fill');
var progressText = form.querySelector('#overwrite-progress-text');
var statusText = model.querySelector('#overwrite-edit-status-text');
if (type === 'http') {
statusText.textContent = '<%:Downloading subscription content...%>';
progressContainer.style.display = 'block';
var progress = 0;
var interval = setInterval(function() {
progress = Math.min(progress + Math.random() * 20, 90);
progressFill.style.width = progress + '%';
progressText.textContent = '<%:Downloading...%> ' + Math.floor(progress) + '%';
}, 200);
}
fetch('/cgi-bin/luci/admin/services/openclash/overwrite_subscribe_info', {
method: 'POST',
body: formData
}).then(r=>r.json()).then(function(data){
if (type === 'http') {
clearInterval(interval);
progressFill.style.width = '100%';
progressText.textContent = '<%:Download completed%> 100%';
}
if (data.status === 'success') {
if (type === 'http') {
statusText.textContent = '<%:Subscription saved successfully%>';
} else {
statusText.textContent = '<%:Saved successfully%>';
}
setTimeout(function() {
document.body.removeChild(ocDiv);
if (newName !== name) {
self.currentConfigFile = '/etc/openclash/overwrite/' + newName;
}
self.loadOverwriteFiles();
setTimeout(function() {
self.loadConfigContent();
var configNameElement = document.getElementById('config-file-name');
if (configNameElement) {
configNameElement.textContent = self.formatDisplayName(self.currentConfigFile);
}
}, 300);
}, 800);
} else {
statusText.textContent = '<%:Failed to save subscription info:%> ' + (data.message || '');
}
});
};
},
closeEditor: function() {
if (this.isModified) {
var r = confirm('<%:You have unsaved changes. Are you sure you want to close?%>');
if (!r) {
return;
}
}
this.hideMergeView();
this.hide();
if (window.OverwriteSubscribeManager && typeof window.OverwriteSubscribeManager.load === 'function') {
window.OverwriteSubscribeManager.load(true);
}
try {
window.dispatchEvent(new Event('oc-overwrite-updated'));
} catch (e) {}
},
updateZoom: function(newZoom) {
this.currentZoom = newZoom;
if (this.editorInstance) {
var cmWrapper = this.model.querySelector('.CodeMirror');
if (cmWrapper) {
this.zoomLevels.forEach(function(level) {
cmWrapper.classList.remove('zoom-' + level);
});
if (this.currentZoom !== 100) {
cmWrapper.classList.add('zoom-' + this.currentZoom);
}
this.editorInstance.refresh();
}
}
},
zoomIn: function() {
var currentIndex = this.zoomLevels.indexOf(this.currentZoom);
if (currentIndex < this.zoomLevels.length - 1) {
this.updateZoom(this.zoomLevels[currentIndex + 1]);
}
},
zoomOut: function() {
var currentIndex = this.zoomLevels.indexOf(this.currentZoom);
if (currentIndex > 0) {
this.updateZoom(this.zoomLevels[currentIndex - 1]);
}
},
resetZoom: function() {
this.updateZoom(100);
},
isPointOnTextContent: function(el, clientX, clientY) {
if (!el) return false;
var textWalker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false);
var textNode;
while ((textNode = textWalker.nextNode())) {
if (!textNode.nodeValue || !textNode.nodeValue.trim()) {
continue;
}
var range = document.createRange();
range.selectNodeContents(textNode);
var rects = range.getClientRects();
for (var i = 0; i < rects.length; i++) {
var rect = rects[i];
if (clientX >= rect.left && clientX <= rect.right && clientY >= rect.top && clientY <= rect.bottom) {
return true;
}
}
}
return false;
},
makeDraggable: function() {
var self = this;
var header = this.model.querySelector('.config-editor-header');
var startX, startY, startLeft, startTop;
var isDragging = false;
header.addEventListener('mousedown', function(e) {
var target = e.target && e.target.nodeType === 1 ? e.target : e.target.parentElement;
if (target && target.closest('.config-editor-actions')) {
return;
}
var textEl = target ? target.closest('#editTitle, #config-file-name') : null;
if (textEl && self.isPointOnTextContent(textEl, e.clientX, e.clientY)) {
return;
}
isDragging = true;
startX = e.clientX;
startY = e.clientY;
var rect = self.model.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
self.model.style.position = 'fixed';
self.model.style.left = startLeft + 'px';
self.model.style.top = startTop + 'px';
self.model.style.margin = '0';
self.model.style.transform = 'none';
self.model.style.transition = 'none';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
e.preventDefault();
});
function onMouseMove(e) {
if (!isDragging) return;
var deltaX = e.clientX - startX;
var deltaY = e.clientY - startY;
var newLeft = startLeft + deltaX;
var newTop = startTop + deltaY;
var modelRect = self.model.getBoundingClientRect();
var maxLeft = window.innerWidth - modelRect.width;
var maxTop = window.innerHeight - modelRect.height;
newLeft = Math.max(0, Math.min(newLeft, maxLeft));
newTop = Math.max(0, Math.min(newTop, maxTop));
self.model.style.left = newLeft + 'px';
self.model.style.top = newTop + 'px';
}
function onMouseUp() {
isDragging = false;
setTimeout(function() {
self.model.style.transition = 'all 0.3s ease';
}, 50);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
}
header.addEventListener('touchstart', function(e) {
var target = e.target && e.target.nodeType === 1 ? e.target : e.target.parentElement;
if (target && target.closest('.config-editor-actions')) {
return;
}
var touch = e.touches && e.touches[0] ? e.touches[0] : null;
var textEl = target ? target.closest('#editTitle, #config-file-name') : null;
if (touch && textEl && self.isPointOnTextContent(textEl, touch.clientX, touch.clientY)) {
return;
}
if (e.touches.length !== 1) return;
isDragging = true;
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
var rect = self.model.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
self.model.style.position = 'fixed';
self.model.style.left = startLeft + 'px';
self.model.style.top = startTop + 'px';
self.model.style.margin = '0';
self.model.style.transform = 'none';
self.model.style.transition = 'none';
document.addEventListener('touchmove', onTouchMove, {passive: false});
document.addEventListener('touchend', onTouchEnd);
e.preventDefault();
});
function onTouchMove(e) {
if (!isDragging || e.touches.length !== 1) return;
var deltaX = e.touches[0].clientX - startX;
var deltaY = e.touches[0].clientY - startY;
var newLeft = startLeft + deltaX;
var newTop = startTop + deltaY;
var modelRect = self.model.getBoundingClientRect();
var maxLeft = window.innerWidth - modelRect.width;
var maxTop = window.innerHeight - modelRect.height;
newLeft = Math.max(0, Math.min(newLeft, maxLeft));
newTop = Math.max(0, Math.min(newTop, maxTop));
self.model.style.left = newLeft + 'px';
self.model.style.top = newTop + 'px';
e.preventDefault();
}
function onTouchEnd() {
isDragging = false;
setTimeout(function() {
self.model.style.transition = 'all 0.3s ease';
}, 50);
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
}
},
makeResizable: function() {
var self = this;
var resizeHandle = document.getElementById('config-editor-resize-handle');
var isResizing = false;
var startX, startY, startWidth, startHeight;
resizeHandle.addEventListener('mousedown', function(e) {
isResizing = true;
startX = e.clientX;
startY = e.clientY;
var rect = self.model.getBoundingClientRect();
startWidth = rect.width;
startHeight = rect.height;
self.model.style.transition = 'none';
self.model.style.width = startWidth + 'px';
self.model.style.height = startHeight + 'px';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
e.preventDefault();
});
function onMouseMove(e) {
if (!isResizing) return;
var deltaX = e.clientX - startX;
var deltaY = e.clientY - startY;
var newWidth = Math.max(400, startWidth + deltaX);
var newHeight = Math.max(300, startHeight + deltaY);
var maxWidth = window.innerWidth * 0.98;
var maxHeight = window.innerHeight * 0.95;
newWidth = Math.min(newWidth, maxWidth);
newHeight = Math.min(newHeight, maxHeight);
self.model.style.width = newWidth + 'px';
self.model.style.height = newHeight + 'px';
if (self.editorInstance) {
requestAnimationFrame(function() {
if (self.mergeViewActive && self.editorInstance.editor) {
self.editorInstance.editor().refresh();
if (self.editorInstance.rightOriginal)
self.editorInstance.rightOriginal().refresh();
} else {
self.editorInstance.refresh();
}
});
}
}
function onMouseUp() {
isResizing = false;
self.model.style.transition = 'all 0.3s ease';
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
if (self.editorInstance) {
setTimeout(function() {
if (self.mergeViewActive && self.editorInstance.editor) {
self.editorInstance.editor().refresh();
if (self.editorInstance.rightOriginal)
self.editorInstance.rightOriginal().refresh();
} else {
self.editorInstance.refresh();
}
}, 50);
}
}
resizeHandle.addEventListener('touchstart', function(e) {
if (e.touches.length !== 1) return;
isResizing = true;
startX = e.touches[0].clientX;
startY = e.touches[0].clientY;
var rect = self.model.getBoundingClientRect();
startWidth = rect.width;
startHeight = rect.height;
self.model.style.transition = 'none';
self.model.style.width = startWidth + 'px';
self.model.style.height = startHeight + 'px';
document.addEventListener('touchmove', onTouchMove, {passive: false});
document.addEventListener('touchend', onTouchEnd);
e.preventDefault();
});
function onTouchMove(e) {
if (!isResizing || e.touches.length !== 1) return;
var deltaX = e.touches[0].clientX - startX;
var deltaY = e.touches[0].clientY - startY;
var newWidth = Math.max(320, startWidth + deltaX);
var newHeight = Math.max(200, startHeight + deltaY);
var maxWidth = window.innerWidth * 0.98;
var maxHeight = window.innerHeight * 0.95;
newWidth = Math.min(newWidth, maxWidth);
newHeight = Math.min(newHeight, maxHeight);
self.model.style.width = newWidth + 'px';
self.model.style.height = newHeight + 'px';
if (self.editorInstance) {
requestAnimationFrame(function() {
if (self.mergeViewActive && self.editorInstance.editor) {
self.editorInstance.editor().refresh();
if (self.editorInstance.rightOriginal)
self.editorInstance.rightOriginal().refresh();
} else {
self.editorInstance.refresh();
}
});
}
e.preventDefault();
}
function onTouchEnd() {
isResizing = false;
self.model.style.transition = 'all 0.3s ease';
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
if (self.editorInstance) {
setTimeout(function() {
if (self.mergeViewActive && self.editorInstance.editor) {
self.editorInstance.editor().refresh();
if (self.editorInstance.rightOriginal)
self.editorInstance.rightOriginal().refresh();
} else {
self.editorInstance.refresh();
}
}, 50);
}
}
}
};
document.addEventListener('DOMContentLoaded', function() {
ConfigEditor.init();
});
</script>