Studio: akış monitörü + Material Icons + saat + başlık büyütme

Akış monitör paneli (sağ kenar, akış varken görünür):
  - Gönderiyorum / Geliyor bölümleri ayrı ayrı
  - Video tile: <video> önizleme, aspect-ratio 16/9
  - Ses tile: mic ikonu + mavi arka plan
  - Her tile: etiket, peer IP/ID, ✕ kapat butonu
  - Track 'ended' olunca tile otomatik kalkar
  - addStream çağrısında localTile eklendi (_call + cihaz seçimi)
  - _watchIncoming → remoteTile eklendi

Material Icons Round (@import Google Fonts):
  - Tüm item ikonları: people/videocam/mic/screen_share/upload_file/
    live_tv/hd/sd/link_off/stop_circle/meeting_room/sensors vb.
  - chevron_right ok ikonu
  - Column.js: icon string → Material Icons textContent,
    HTML içeriyorsa innerHTML (ikonlu butonlar için)
  - addAction label: innerHTML → butonlara ikon eklenebilir
  - Bildirim banner'ında wifi ikonu

Araç çubuğu:
  - Başlık: 12px → 17px, font-weight 700, yükseklik 46px
  - ID kartı: "Kimliğim" etiket metni kaldırıldı, sadece IP + UUID + ⎘
  - Sağ üst köşe: HH:MM:SS canlı saat (setInterval 1s)

Layout:
  - .mwse-studio__main: flex-row → kolonlar sol, panel sağ
  - ColumnView artık mainArea'ya mount ediliyor

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
abdussamedulutas 2026-06-17 13:54:11 +03:00
parent 0c654ae4c8
commit c1d1ddf383
3 changed files with 491 additions and 501 deletions

View File

@ -65,7 +65,7 @@ export default class Column {
}
const btn = document.createElement('button');
btn.className = `mwse-btn ${className ?? ''}`;
btn.textContent = label;
btn.innerHTML = label; // HTML destekler (ikonlu etiketler için)
btn.addEventListener('click', onClick);
this._actionsEl.appendChild(btn);
}
@ -96,7 +96,12 @@ export default class Column {
const icon = document.createElement('span');
icon.className = 'mwse-item__icon';
icon.textContent = item.icon ?? '○';
// Material Icons: icon adı string ise text, HTML içeriyorsa innerHTML
if (item.icon?.includes('<')) {
icon.innerHTML = item.icon;
} else {
icon.textContent = item.icon ?? 'circle';
}
row.appendChild(icon);
const body = document.createElement('div');
@ -120,7 +125,7 @@ export default class Column {
if (item.hasChildren !== false) {
const arrow = document.createElement('span');
arrow.className = 'mwse-item__arrow';
arrow.textContent = '';
arrow.textContent = 'chevron_right';
row.appendChild(arrow);
}

View File

@ -1,5 +1,4 @@
// MWSE Studio — dahili yönetim arayüzü.
// Akış: bağlan → ID'yi kopyala → eşle → WebRTC ara / oda oluştur
import ColumnView from '/studio/ColumnView.js';
import { MediaSources } from '/sdk/webrtc/index.js';
@ -8,12 +7,16 @@ export default class Studio {
this.mwse = mwse;
this._el = typeof container === 'string'
? document.querySelector(container) : container;
this._view = new ColumnView(this._el);
this._view = null; // mount() içinde oluşturulur
this._devices = { cameras: [], microphones: [] };
this._statusEl = null;
this._notifArea = null;
this._myIPEl = null;
this._myUUIDEl = null;
this._localGrid = null; // "Gönderiyorum" tile grid'i
this._remoteGrid = null; // "Geliyor" tile grid'i
this._streamsPanel = null;
this._styleOK = false;
this._remoteMedia = [];
}
async mount() {
@ -21,30 +24,36 @@ export default class Studio {
this._injectStyle();
this._buildToolbar();
this._buildNotifArea();
// Ana alan: kolonlar (sol) + akış monitörü (sağ)
const mainArea = document.createElement('div');
mainArea.className = 'mwse-studio__main';
this._el.appendChild(mainArea);
this._view = new ColumnView(mainArea);
this._view.mount();
this._streamsPanel = this._buildStreamsPanel(mainArea);
await this._loadDevices();
this._pushRootColumn();
// Scope sonrası sanal IP al ve kendime ata.
// Sanal IP al
this.mwse.scope(async () => {
try {
const ip = await this.mwse.virtualPressure.allocAPIPAddress();
// PeerInfo.set → auth/info → mevcut tüm pairlere yayımlar.
this.mwse.me.info.set('ip', ip);
this._updateMyIP(ip);
if (this._myIPEl) this._myIPEl.textContent = ip;
if (this._myUUIDEl) this._myUUIDEl.textContent = this.mwse.me.socketId.slice(-8);
} catch (_) {}
});
// ── Gelen eşleme isteği → bildirim banner'ı ──────────────────────
this.mwse.me.on('request/pair', peer => {
this._showPairRequest(peer);
});
// Gelen eşleme isteği → bildirim banner
this.mwse.me.on('request/pair', peer => this._showPairRequest(peer));
// Eşleşme onaylandı (istek gönderen taraf)
// Eşleşme onaylandı
this.mwse.me.on('accepted/pair', peer => {
this._watchIncoming(peer);
// Yeni paire kendi IP'mizi paylaş.
if (this.mwse.virtualPressure.APIPAddress) {
this.mwse.me.info.set('ip', this.mwse.virtualPressure.APIPAddress);
}
@ -52,91 +61,201 @@ export default class Studio {
this._setStatus('online', `${this._peerLabel(peer)} eşleşmesi kuruldu`);
});
// Pair info değişince (karşı tarafın IP'si gelince) kolonları yenile.
this.mwse.me.on('accepted/pair', () => this._view.refresh());
this.mwse.on('room', () => this._view.refresh());
this.mwse.me.on('end/pair', () => this._view.refresh());
this.mwse.on('room', () => this._view.refresh());
return this;
}
// Toolbar'daki IP/ID kartını güncelle.
_updateMyIP(ip) {
if (this._myIPEl) this._myIPEl.textContent = ip;
}
// Bir peer için görünen ad: sanal IP varsa onu, yoksa kısa UUID.
_peerLabel(peer) {
return peer.info?.info?.ip || peer.socketId.slice(-8);
}
// ── Araç çubuğu ──────────────────────────────────────────────────────────
_buildToolbar() {
const bar = document.createElement('div');
bar.className = 'mwse-studio__toolbar';
// Logo
const logo = document.createElement('span');
logo.className = 'mwse-studio__title';
logo.innerHTML = 'MWSE <span style="color:#0078d4">Studio</span>';
bar.appendChild(logo);
// Benim IP + ID kartı (tıkla → UUID kopyalanır)
// ID kartı: sadece IP + kısa UUID + kopyala
const idCard = document.createElement('div');
idCard.className = 'mwse-id-card';
idCard.title = 'Socket ID\'yi kopyala (paylaşmak için)';
idCard.title = 'Socket ID kopyala (bağlantı paylaşımı için)';
const idLabel = document.createElement('span');
idLabel.className = 'mwse-id-card__label';
idLabel.textContent = 'Kimliğim';
// Sanal IP (önce boş, allocAPIPAddress sonrası dolar)
this._myIPEl = document.createElement('span');
this._myIPEl.className = 'mwse-id-card__ip';
this._myIPEl.textContent = '';
this._myIPEl.textContent = '●'; // IP gelmeden önce nokta
// Kısa UUID (ID paylaşımı için)
const idValue = document.createElement('span');
idValue.className = 'mwse-id-card__value';
idValue.textContent = '…';
this._myUUIDEl = document.createElement('span');
this._myUUIDEl.className = 'mwse-id-card__value';
this._myUUIDEl.textContent = '…';
const idCopy = document.createElement('span');
idCopy.className = 'mwse-id-card__copy';
idCopy.textContent = '⎘';
const copyIcon = document.createElement('span');
copyIcon.className = 'mwse-id-card__copy';
copyIcon.textContent = '⎘';
idCard.append(idLabel, this._myIPEl, idValue, idCopy);
idCard.append(this._myIPEl, this._myUUIDEl, copyIcon);
idCard.addEventListener('click', () => {
const id = this.mwse.me.socketId;
if (!id || id === '…') return;
if (!id) return;
navigator.clipboard.writeText(id).then(() => {
idCopy.textContent = '✓';
copyIcon.textContent = '✓';
idCard.classList.add('mwse-id-card--copied');
setTimeout(() => {
idCard.classList.remove('mwse-id-card--copied');
idCopy.textContent = '⎘';
copyIcon.textContent = '⎘';
}, 2000);
});
});
this.mwse.me.on('scope', () => { idValue.textContent = this.mwse.me.socketId.slice(-8); });
bar.appendChild(idCard);
// Durum
this._statusEl = document.createElement('span');
this._statusEl.className = 'mwse-studio__status mwse-studio__status--online';
this._statusEl.textContent = 'Bağlı';
this._statusEl.innerHTML = '<span class="mi mi-sm">wifi</span> Bağlı';
bar.appendChild(this._statusEl);
// Canlı saat
const clock = document.createElement('span');
clock.className = 'mwse-studio__clock';
const tick = () => {
const now = new Date();
clock.textContent = now.toTimeString().slice(0, 8);
};
tick(); setInterval(tick, 1000);
bar.appendChild(clock);
this._el.insertBefore(bar, this._el.firstChild);
}
// ── Akış monitör paneli ───────────────────────────────────────────────────
_buildStreamsPanel(parent) {
const panel = document.createElement('div');
panel.className = 'mwse-streams-panel';
panel.style.display = 'none'; // akış yokken gizli
// Gönderiyorum bölümü
const outSec = document.createElement('div');
outSec.className = 'mwse-streams-section';
const outTitle = document.createElement('div');
outTitle.className = 'mwse-streams-section__title';
outTitle.textContent = '▲ Gönderiyorum';
this._localGrid = document.createElement('div');
this._localGrid.className = 'mwse-streams-grid';
outSec.append(outTitle, this._localGrid);
// Geliyor bölümü
const inSec = document.createElement('div');
inSec.className = 'mwse-streams-section';
const inTitle = document.createElement('div');
inTitle.className = 'mwse-streams-section__title';
inTitle.textContent = '▼ Geliyor';
this._remoteGrid = document.createElement('div');
this._remoteGrid.className = 'mwse-streams-grid';
inSec.append(inTitle, this._remoteGrid);
panel.append(outSec, inSec);
parent.appendChild(panel);
return panel;
}
// Yerel (gönderilen) akış tile'ı ekle
_addLocalTile(label, stream, peerLabel) {
const hasVideo = stream.getVideoTracks().length > 0;
const tile = this._makeTile(
label,
`${peerLabel}`,
stream,
hasVideo,
true, // muted (geri besleme yok)
() => tile.remove() && this._updatePanelVisibility()
);
this._localGrid.appendChild(tile);
this._updatePanelVisibility();
}
// Uzak (gelen) track tile'ı ekle
_addRemoteTile(track, peerLabel) {
const isVideo = track.kind === 'video';
const stream = new MediaStream([track]);
const tile = this._makeTile(
isVideo ? 'Video' : 'Ses',
`${peerLabel}`,
stream,
isVideo,
!isVideo, // ses tile'ı için muted değil (çalması lazım)
() => tile.remove() && this._updatePanelVisibility()
);
// Ses için görünmez <audio> da ekle
if (!isVideo) {
const audio = new Audio();
audio.autoplay = true;
audio.srcObject = stream;
document.body.appendChild(audio);
}
this._remoteGrid.appendChild(tile);
this._updatePanelVisibility();
// Track bitince tile'ı kaldır
track.addEventListener('ended', () => {
tile.remove();
this._updatePanelVisibility();
});
}
_makeTile(label, peerInfo, stream, isVideo, muted, onClose) {
const tile = document.createElement('div');
tile.className = `mwse-stream-tile${isVideo ? '' : ' mwse-stream-tile--audio'}`;
if (isVideo) {
const video = document.createElement('video');
video.autoplay = true;
video.playsInline = true;
video.muted = muted;
video.srcObject = stream;
video.className = 'mwse-stream-tile__video';
tile.appendChild(video);
} else {
const icon = document.createElement('div');
icon.className = 'mwse-stream-tile__audio-icon';
icon.textContent = '♪';
tile.appendChild(icon);
}
const info = document.createElement('div');
info.className = 'mwse-stream-tile__info';
const lbl = document.createElement('span');
lbl.className = 'mwse-stream-tile__label';
lbl.textContent = label;
const peer = document.createElement('span');
peer.className = 'mwse-stream-tile__peer';
peer.textContent = peerInfo;
const closeBtn = document.createElement('span');
closeBtn.className = 'mwse-stream-tile__close';
closeBtn.textContent = '✕';
closeBtn.title = 'Kapat';
closeBtn.addEventListener('click', e => { e.stopPropagation(); onClose(); });
info.append(lbl, peer, closeBtn);
tile.appendChild(info);
return tile;
}
_updatePanelVisibility() {
const hasLocal = this._localGrid.childElementCount > 0;
const hasRemote = this._remoteGrid.childElementCount > 0;
this._streamsPanel.style.display = (hasLocal || hasRemote) ? '' : 'none';
}
// ── Gelen istek bildirim alanı ────────────────────────────────────────────
_buildNotifArea() {
this._notifArea = document.createElement('div');
this._notifArea.className = 'mwse-notif-area';
// Araç çubuğundan hemen sonra
const toolbar = this._el.querySelector('.mwse-studio__toolbar');
toolbar.insertAdjacentElement('afterend', this._notifArea);
}
@ -172,10 +291,12 @@ export default class Studio {
bar.remove();
if (ok) {
this._watchIncoming(peer);
// Kabul eden tarafta Eşler kolonuna yönlendir.
if (this.mwse.virtualPressure.APIPAddress) {
this.mwse.me.info.set('ip', this.mwse.virtualPressure.APIPAddress);
}
this._view.popTo(1);
this._pushPeersColumn();
this._setStatus('online', `${peer.socketId.slice(-8)} kabul edildi`);
this._setStatus('online', `${this._peerLabel(peer)} kabul edildi`);
} else {
this._setStatus('error', 'Eşleşme kurulamadı');
}
@ -191,32 +312,22 @@ export default class Studio {
_pushRootColumn() {
const items = [
{
icon: '●',
label: 'Eşler',
meta: () => {
const n = this.mwse.pairs.size;
return n ? `${n} aktif eşleşme` : 'Henüz eş yok';
},
icon: 'people', label: 'Eşler',
meta: () => { const n = this.mwse.pairs.size; return n ? `${n} eşleşme` : 'Henüz yok'; },
onSelect: () => { this._view.popTo(1); this._pushPeersColumn(); }
},
{
icon: '#',
label: 'Odalar',
meta: () => {
const n = this.mwse.rooms.size;
return n ? `${n} oda` : 'Oda yok';
},
icon: 'meeting_room', label: 'Odalar',
meta: () => { const n = this.mwse.rooms.size; return n ? `${n} oda` : 'Oda yok'; },
onSelect: () => { this._view.popTo(1); this._pushRoomsColumn(); }
},
{
icon: '◎',
label: 'Kameralar',
icon: 'videocam', label: 'Kameralar',
meta: () => `${this._devices.cameras.length} kamera`,
onSelect: () => { this._view.popTo(1); this._pushDevicesColumn(null, 'video'); }
},
{
icon: '♪',
label: 'Mikrofonlar',
icon: 'mic', label: 'Mikrofonlar',
meta: () => `${this._devices.microphones.length} mikrofon`,
onSelect: () => { this._view.popTo(1); this._pushDevicesColumn(null, 'audio'); }
}
@ -229,7 +340,7 @@ export default class Studio {
_pushPeersColumn() {
const pairs = [...this.mwse.pairs.values()];
const items = pairs.map(peer => ({
icon: peer.rtc?.active ? '◉' : '●',
icon: peer.rtc?.active ? 'sensors' : 'person',
label: this._peerLabel(peer),
meta: () => this._peerMeta(peer),
onSelect: () => { this._view.popTo(2); this._pushPeerColumn(peer); }
@ -237,32 +348,33 @@ export default class Studio {
if (!items.length) {
items.push({
icon: '', label: 'Henüz eş yok',
meta: '"ID ile ara" → eş socket ID\'sini gir', hasChildren: false
icon: 'person_off', label: 'Henüz eş yok',
meta: '"ID ile ara" butonunu kullan', hasChildren: false
});
}
const col = this._view.pushColumn('Eşler', items);
col.addAction('ID ile ara', '', () => {
col.addAction('<span class="mi mi-sm">person_search</span> ID ile ara', '', () => {
this._showModal({
title: 'Eşe bağlan',
fields: [
{ key: 'id', label: 'Socket ID', placeholder: 'xxxxxxxx-xxxx-…' }
],
fields: [{ key: 'id', label: 'Socket ID', placeholder: 'xxxxxxxx-xxxx-…' }],
confirm: 'Bağlan',
onConfirm: ({ id }) => {
if (!id) return;
this.mwse.peer(id).requestPair()
.then(ok => {
if (ok) this._setStatus('', `${id.slice(-8)}'e istek gönderildi — kabul bekleniyor`);
else this._setStatus('error', 'İstek gönderilemedi');
})
.then(ok => ok
? this._setStatus('', `${id.slice(-8)}'e istek gönderildi`)
: this._setStatus('error', 'İstek gönderilemedi'))
.catch(e => this._setStatus('error', e.message));
}
});
});
}
_peerLabel(peer) {
return peer.info?.info?.ip || peer.socketId.slice(-8);
}
_peerMeta(peer) {
const streams = peer.rtc?._streams?.list() ?? [];
const id = peer.socketId.slice(-8);
@ -277,50 +389,29 @@ export default class Studio {
const streams = peer.rtc?._streams?.list() ?? [];
const items = [
{
icon: '▶', label: 'Video + Ses', meta: 'Kamera ve mikrofon',
onSelect: () => this._call(peer, 'cam+mic')
},
{
icon: '♪', label: 'Sesli Ara', meta: 'Yalnızca mikrofon',
onSelect: () => this._call(peer, 'mic')
},
{
icon: '⬜', label: 'Ekran Paylaş', meta: 'getDisplayMedia',
onSelect: () => this._call(peer, 'screen')
},
{
icon: '◎', label: 'Kamera Seç',
meta: `${this._devices.cameras.length} kamera`,
onSelect: () => { this._view.popTo(3); this._pushDevicesColumn(peer, 'video'); }
},
{
icon: '♪', label: 'Mikrofon Seç',
meta: `${this._devices.microphones.length} mikrofon`,
onSelect: () => { this._view.popTo(3); this._pushDevicesColumn(peer, 'audio'); }
},
{
icon: '↗', label: 'Dosya Gönder', meta: 'P2P DataChannel',
hasChildren: false,
onSelect: () => this._sendFile(peer)
}
{ icon: 'videocam', label: 'Video + Ses', meta: 'Kamera ve mikrofon', onSelect: () => this._call(peer, 'cam+mic') },
{ icon: 'mic', label: 'Sesli Ara', meta: 'Yalnızca mikrofon', onSelect: () => this._call(peer, 'mic') },
{ icon: 'screen_share', label: 'Ekran Paylaş', meta: 'getDisplayMedia', onSelect: () => this._call(peer, 'screen') },
{ icon: 'switch_video', label: 'Kamera Seç', meta: `${this._devices.cameras.length} kamera`,
onSelect: () => { this._view.popTo(3); this._pushDevicesColumn(peer, 'video'); } },
{ icon: 'settings_voice', label: 'Mikrofon Seç', meta: `${this._devices.microphones.length} mikrofon`,
onSelect: () => { this._view.popTo(3); this._pushDevicesColumn(peer, 'audio'); } },
{ icon: 'upload_file', label: 'Dosya Gönder', meta: 'P2P DataChannel', hasChildren: false,
onSelect: () => this._sendFile(peer) }
];
if (streams.length) {
items.push({
icon: '◉',
label: `Aktif Akışlar (${streams.length})`,
icon: 'live_tv', label: `Aktif Akışlar (${streams.length})`,
meta: streams.map(s => s.label).join(' · '),
onSelect: () => { this._view.popTo(3); this._pushStreamsColumn(peer); }
});
}
items.push({
icon: '⊘', label: 'Eşleşmeyi Bitir', meta: '', hasChildren: false,
items.push({ icon: 'link_off', label: 'Eşleşmeyi Bitir', meta: '', hasChildren: false,
onSelect: async () => {
await peer.endPair().catch(() => {});
this._view.popTo(1);
this._pushPeersColumn();
this._view.popTo(1); this._pushPeersColumn();
}
});
@ -330,21 +421,17 @@ export default class Studio {
// ── Odalar kolonu ─────────────────────────────────────────────────────────
_pushRoomsColumn() {
const items = [...this.mwse.rooms.entries()].map(([id, room]) => ({
icon: '#',
label: room.config?.name ?? id,
const items = [...this.mwse.rooms.entries()].map(([, room]) => ({
icon: 'meeting_room',
label: room.config?.name ?? room.roomId,
meta: () => `${room.peers.size} üye`,
onSelect: () => { this._view.popTo(2); this._pushRoomMembersColumn(room); }
}));
if (!items.length) {
items.push({
icon: '—', label: 'Oda yok', meta: '"Oda Oluştur" ile başla', hasChildren: false
});
}
if (!items.length) items.push({ icon: 'block', label: 'Oda yok', meta: '"Oda Oluştur" butonunu kullan', hasChildren: false });
const col = this._view.pushColumn('Odalar', items);
col.addAction('Oda Oluştur', 'mwse-btn--primary', () => {
col.addAction('<span class="mi mi-sm">add</span> Oda Oluştur', 'mwse-btn--primary', () => {
this._showModal({
title: 'Yeni Oda',
fields: [
@ -356,43 +443,35 @@ export default class Studio {
onConfirm: async ({ name, desc, pass }) => {
if (!name) return;
const room = this.mwse.room({
name,
description: desc || name,
name, description: desc || name,
joinType: pass ? 'password' : 'free',
accessType: 'public',
ifexistsJoin: true,
notifyActionJoined: true,
notifyActionEjected: true,
notifyActionInvite: false,
accessType: 'public', ifexistsJoin: true,
notifyActionJoined: true, notifyActionEjected: true, notifyActionInvite: false,
...(pass ? { password: pass } : {})
});
await room.createRoom();
this._setStatus('online', `"${name}" odası oluşturuldu`);
this._view.popTo(1);
this._pushRoomsColumn();
this._setStatus('online', `"${name}" oluşturuldu`);
this._view.popTo(1); this._pushRoomsColumn();
}
});
});
}
_pushRoomMembersColumn(room) {
const myIP = this.mwse.virtualPressure.APIPAddress;
const items = [...room.peers.values()].map(peer => ({
icon: peer.selfSocket ? '★' : '●',
label: peer.selfSocket ? (this.mwse.virtualPressure.APIPAddress || 'Ben') : this._peerLabel(peer),
icon: peer.selfSocket ? 'star' : 'person',
label: peer.selfSocket ? (myIP || 'Ben') : this._peerLabel(peer),
meta: () => this._peerMeta(peer),
onSelect: () => { this._view.popTo(3); this._pushPeerColumn(peer); }
}));
if (!items.length) {
items.push({ icon: '—', label: 'Üye yok', meta: '', hasChildren: false });
}
if (!items.length) items.push({ icon: '—', label: 'Üye yok', meta: '', hasChildren: false });
const roomName = room.config?.name ?? room.roomId;
const col = this._view.pushColumn(roomName, items);
col.addAction('Odadan Çık', 'mwse-btn--danger', async () => {
const col = this._view.pushColumn(room.config?.name ?? room.roomId, items);
col.addAction('<span class="mi mi-sm">exit_to_app</span> Çık', 'mwse-btn--danger', async () => {
await room.eject().catch(() => {});
this._view.popTo(1);
this._pushRoomsColumn();
this._view.popTo(1); this._pushRoomsColumn();
});
}
@ -403,7 +482,7 @@ export default class Studio {
const title = kind === 'audio' ? 'Mikrofonlar' : 'Kameralar';
const items = list.map(dev => ({
icon: kind === 'audio' ? '♪' : '◎',
icon: kind === 'audio' ? 'mic' : 'videocam',
label: dev.label || `Cihaz ${dev.deviceId.slice(-6)}`,
meta: dev.deviceId.slice(-8),
onSelect: async () => {
@ -421,78 +500,64 @@ export default class Studio {
const label = dev.label || dev.deviceId.slice(-6);
if (peer.rtc._streams?.has(label)) peer.rtc.removeStream(label);
peer.rtc.addStream(label, stream);
this._view.popTo(4);
this._pushStreamsColumn(peer);
this._addLocalTile(label, stream, this._peerLabel(peer));
this._view.popTo(4); this._pushStreamsColumn(peer);
} else {
this._previewStream(stream, dev.label || title);
}
}
}));
if (!items.length) {
items.push({
icon: '—', label: 'Cihaz bulunamadı',
meta: '"İzin İste" butonunu dene', hasChildren: false
});
}
if (!items.length) items.push({ icon: 'perm_media', label: 'Cihaz bulunamadı', meta: '"İzin İste" butonunu kullan', hasChildren: false });
const col = this._view.pushColumn(title, items);
col.addAction('İzin İste', '', async () => {
await navigator.mediaDevices.getUserMedia(
kind === 'audio' ? { audio: true } : { video: true }
).catch(() => {});
col.addAction('<span class="mi mi-sm">lock_open</span> İzin İste', '', async () => {
await navigator.mediaDevices.getUserMedia(kind === 'audio' ? { audio: true } : { video: true }).catch(() => {});
await this._loadDevices();
this._view.popTo(this._view.depth - 1);
this._pushDevicesColumn(peer, kind);
});
}
// ── Akışlar kolonu ────────────────────────────────────────────────────────
// ── Akışlar / Kalite kolonları ────────────────────────────────────────────
_pushStreamsColumn(peer) {
const srcs = peer.rtc?._streams?.list() ?? [];
const items = srcs.map(src => ({
icon: src.tracks[0]?.kind === 'video' ? '▶' : '♪',
icon: src.tracks[0]?.kind === 'video' ? 'live_tv' : 'graphic_eq',
label: src.label,
meta: src.tracks.map(t => `${t.kind}${t.enabled ? '' : ' (sessiz)'}`).join(' + '),
onSelect: () => { this._view.popTo(4); this._pushQualityColumn(peer, src.label, src); }
}));
if (!items.length) {
items.push({ icon: '—', label: 'Akış yok', meta: '', hasChildren: false });
}
if (!items.length) items.push({ icon: '—', label: 'Akış yok', meta: '', hasChildren: false });
this._view.pushColumn('Akışlar', items);
}
// ── Kalite / kontrol ─────────────────────────────────────────────────────
_pushQualityColumn(peer, label, src) {
const presets = [
{ icon: '↑', label: 'Yüksek', meta: '1080p · 4 Mbps', params: { maxBitrate: 4_000_000, scaleResolutionDownBy: 1 } },
{ icon: '—', label: 'Orta', meta: '720p · 1.5 Mbps', params: { maxBitrate: 1_500_000, scaleResolutionDownBy: 1.5 } },
{ icon: '', label: 'Düşük', meta: '480p · 500 Kbps', params: { maxBitrate: 500_000, scaleResolutionDownBy: 2 } },
{ icon: 'hd', label: 'Yüksek', meta: '1080p · 4 Mbps', params: { maxBitrate: 4_000_000, scaleResolutionDownBy: 1 } },
{ icon: 'sd', label: 'Orta', meta: '720p · 1.5 Mbps', params: { maxBitrate: 1_500_000, scaleResolutionDownBy: 1.5 } },
{ icon: 'signal_cellular_1_bar', label: 'Düşük', meta: '480p · 500 Kbps', params: { maxBitrate: 500_000, scaleResolutionDownBy: 2 } },
];
const items = presets.map(p => ({
icon: p.icon, label: p.label, meta: p.meta, hasChildren: false,
onSelect: () => peer.rtc?.setEncodings(label, 'video', p.params)
}));
for (const track of (src.tracks ?? [])) {
items.push({
icon: track.enabled ? '⊙' : '○',
icon: track.enabled
? (track.kind === 'video' ? 'videocam_off' : 'mic_off')
: (track.kind === 'video' ? 'videocam' : 'mic'),
label: `${track.kind === 'video' ? 'Video' : 'Ses'} ${track.enabled ? 'Sustur' : 'Aç'}`,
meta: '', hasChildren: false,
onSelect: () => { peer.rtc?.setEnabled(label, track.kind, !track.enabled); this._view.refresh(); }
});
}
items.push({
icon: '', label: 'Akışı Durdur', meta: '', hasChildren: false,
icon: 'stop_circle', label: 'Akışı Durdur', meta: '', hasChildren: false,
onSelect: () => { peer.rtc?.removeStream(label); this._view.popTo(3); this._pushStreamsColumn(peer); }
});
this._view.pushColumn('Kalite', items, { searchable: false });
}
@ -505,14 +570,12 @@ export default class Studio {
if (type === 'cam+mic') stream = await MediaSources.cameraAndMic();
else if (type === 'mic') stream = await MediaSources.microphone();
else if (type === 'screen') stream = await MediaSources.screen();
} catch (e) {
this._setStatus('error', e.message);
return;
}
// Aynı label zaten varsa önce durdur (kullanıcı ikinci kez tıkladı).
} catch (e) { this._setStatus('error', e.message); return; }
if (peer.rtc._streams?.has(type)) peer.rtc.removeStream(type);
peer.rtc.addStream(type, stream);
this._setStatus('online', `${type}${peer.socketId.slice(-8)}`);
this._addLocalTile(type, stream, this._peerLabel(peer));
this._setStatus('online', `${type}${this._peerLabel(peer)}`);
this._view.popTo(3);
this._pushStreamsColumn(peer);
}
@ -536,20 +599,14 @@ export default class Studio {
_ensureRTC(peer) {
if (peer.rtc?._pc) return;
const polite = this.mwse.me.socketId < peer.socketId;
peer.rtc.connect({ polite });
peer.rtc.connect({ polite: this.mwse.me.socketId < peer.socketId });
}
// Gelen track'leri izle → panel'e ekle
_watchIncoming(peer) {
peer.rtc.on('track', track => {
if (track.kind === 'audio') {
const audio = new Audio();
audio.autoplay = true;
audio.srcObject = new MediaStream([track]);
document.body.appendChild(audio);
this._remoteMedia.push(audio);
}
this._setStatus('online', `${peer.socketId.slice(-8)} ${track.kind} gönderdi`);
this._addRemoteTile(track, this._peerLabel(peer));
this._setStatus('online', `${this._peerLabel(peer)} ${track.kind}`);
});
}
@ -557,24 +614,18 @@ export default class Studio {
document.getElementById('mwse-preview')?.remove();
const wrap = document.createElement('div');
wrap.id = 'mwse-preview';
Object.assign(wrap.style, {
position: 'fixed', bottom: '12px', right: '12px', width: '220px',
background: '#111', border: '1px solid #333', borderRadius: '6px',
overflow: 'hidden', zIndex: '9000'
});
Object.assign(wrap.style, { position:'fixed', bottom:'12px', right:'12px', width:'220px',
background:'#111', border:'1px solid #333', borderRadius:'6px', overflow:'hidden', zIndex:'9000' });
const bar = document.createElement('div');
Object.assign(bar.style, {
padding: '4px 8px', fontSize: '11px', color: '#aaa', background: '#1a1a1a',
display: 'flex', justifyContent: 'space-between', alignItems: 'center'
});
const closeBtn = document.createElement('span');
closeBtn.textContent = '✕'; closeBtn.style.cursor = 'pointer';
closeBtn.addEventListener('click', () => { stream.getTracks().forEach(t => t.stop()); wrap.remove(); });
bar.append(document.createTextNode(label), closeBtn);
Object.assign(bar.style, { padding:'4px 8px', fontSize:'11px', color:'#aaa', background:'#1a1a1a',
display:'flex', justifyContent:'space-between', alignItems:'center' });
const close = document.createElement('span');
close.textContent = '✕'; close.style.cursor = 'pointer';
close.addEventListener('click', () => { stream.getTracks().forEach(t => t.stop()); wrap.remove(); });
bar.append(document.createTextNode(label), close);
const video = document.createElement('video');
video.autoplay = true; video.muted = true; video.playsInline = true;
video.srcObject = stream;
video.style.cssText = 'width:100%;display:block;background:#000';
video.srcObject = stream; video.style.cssText = 'width:100%;display:block;background:#000';
wrap.append(bar, video);
document.body.appendChild(wrap);
}
@ -584,56 +635,40 @@ export default class Studio {
_showModal({ title, fields = [], confirm = 'Tamam', onConfirm }) {
const overlay = document.createElement('div');
overlay.className = 'mwse-modal-overlay';
const modal = document.createElement('div');
modal.className = 'mwse-modal';
const header = document.createElement('div');
header.className = 'mwse-modal__header';
const titleEl = document.createElement('span');
titleEl.textContent = title;
const closeX = document.createElement('span');
closeX.className = 'mwse-modal__close';
closeX.textContent = '✕';
closeX.className = 'mwse-modal__close'; closeX.textContent = '✕';
closeX.addEventListener('click', () => overlay.remove());
header.append(titleEl, closeX);
const body = document.createElement('div');
body.className = 'mwse-modal__body';
const inputs = {};
for (const f of fields) {
const wrap = document.createElement('div');
wrap.className = 'mwse-modal__field';
const lbl = document.createElement('label');
lbl.textContent = f.label;
const lbl = document.createElement('label'); lbl.textContent = f.label;
const inp = document.createElement('input');
inp.className = 'mwse-modal__input';
inp.placeholder = f.placeholder ?? '';
inp.type = f.type ?? 'text';
inp.className = 'mwse-modal__input'; inp.placeholder = f.placeholder ?? ''; inp.type = f.type ?? 'text';
inputs[f.key] = inp;
inp.addEventListener('keydown', e => {
if (e.key === 'Enter') confirmBtn.click();
if (e.key === 'Escape') overlay.remove();
});
wrap.append(lbl, inp);
body.appendChild(wrap);
inp.addEventListener('keydown', e => { if (e.key==='Enter') confirmBtn.click(); if (e.key==='Escape') overlay.remove(); });
wrap.append(lbl, inp); body.appendChild(wrap);
}
const footer = document.createElement('div');
footer.className = 'mwse-modal__footer';
const cancelBtn = document.createElement('button');
cancelBtn.className = 'mwse-btn';
cancelBtn.textContent = 'İptal';
cancelBtn.className = 'mwse-btn'; cancelBtn.textContent = 'İptal';
cancelBtn.addEventListener('click', () => overlay.remove());
const confirmBtn = document.createElement('button');
confirmBtn.className = 'mwse-btn mwse-btn--primary';
confirmBtn.textContent = confirm;
confirmBtn.className = 'mwse-btn mwse-btn--primary'; confirmBtn.textContent = confirm;
confirmBtn.addEventListener('click', () => {
const values = {};
for (const [k, el] of Object.entries(inputs)) values[k] = el.value.trim();
overlay.remove();
onConfirm(values);
overlay.remove(); onConfirm(values);
});
footer.append(cancelBtn, confirmBtn);
modal.append(header, body, footer);
@ -652,10 +687,7 @@ export default class Studio {
_setStatus(cls, text) {
if (!this._statusEl) return;
this._statusEl.className = [
'mwse-studio__status',
cls ? `mwse-studio__status--${cls}` : ''
].join(' ').trim();
this._statusEl.className = ['mwse-studio__status', cls ? `mwse-studio__status--${cls}` : ''].join(' ').trim();
this._statusEl.textContent = text;
}
@ -663,7 +695,6 @@ export default class Studio {
if (this._styleOK) return;
const link = document.createElement('link');
link.rel = 'stylesheet'; link.href = '/studio/style.css';
document.head.appendChild(link);
this._styleOK = true;
document.head.appendChild(link); this._styleOK = true;
}
}

View File

@ -1,4 +1,5 @@
/* MWSE Studio — desktop-first Miller-column UI */
@import url('https://fonts.googleapis.com/icon?family=Material+Icons+Round');
.mwse-studio {
display: flex;
@ -13,35 +14,70 @@
user-select: none;
}
/* Top toolbar */
/* Material icon helper: inline ikon */
.mi {
font-family: 'Material Icons Round';
font-style: normal;
font-size: 16px;
line-height: 1;
vertical-align: middle;
display: inline-block;
}
.mi-sm { font-size: 14px; }
.mi-lg { font-size: 20px; }
/* ── Araç çubuğu ─────────────────────────────────────────────────────── */
.mwse-studio__toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: #111;
border-bottom: 1px solid #333;
gap: 10px;
padding: 0 14px;
height: 46px;
background: #0d0d0d;
border-bottom: 1px solid #2a2a2a;
flex-shrink: 0;
}
.mwse-studio__title {
font-weight: 600;
font-weight: 700;
color: #fff;
font-size: 12px;
letter-spacing: .04em;
text-transform: uppercase;
font-size: 17px;
letter-spacing: .03em;
white-space: nowrap;
}
.mwse-studio__status {
margin-left: auto;
font-size: 11px;
color: #888;
white-space: nowrap;
display: flex;
align-items: center;
gap: 4px;
}
.mwse-studio__status--online { color: #4caf50; }
.mwse-studio__status--error { color: #f44336; }
/* Column scroll area */
/* Saat */
.mwse-studio__clock {
margin-left: auto;
font-family: 'Consolas', monospace;
font-size: 13px;
color: #666;
letter-spacing: .04em;
}
/* ── Ana alan (kolonlar + akış paneli yan yana) ──────────────────────── */
.mwse-studio__main {
display: flex;
flex-direction: row;
flex: 1;
overflow: hidden;
}
/* ── Kolon kaydırma alanı ─────────────────────────────────────────────── */
.mwse-studio__columns {
display: flex;
flex-direction: row;
@ -51,12 +87,96 @@
scrollbar-width: thin;
scrollbar-color: #444 #1a1a1a;
}
.mwse-studio__columns::-webkit-scrollbar { height: 4px; }
.mwse-studio__columns::-webkit-scrollbar-track { background: #1a1a1a; }
.mwse-studio__columns::-webkit-scrollbar-thumb { background: #444; border-radius: 2px; }
/* Individual column */
/* ── Akış monitör paneli ─────────────────────────────────────────────── */
.mwse-streams-panel {
width: 260px;
min-width: 260px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: #141414;
border-left: 1px solid #2a2a2a;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #333 transparent;
}
.mwse-streams-section {
padding: 10px 0 4px;
}
.mwse-streams-section__title {
padding: 0 12px 6px;
font-size: 10px;
font-weight: 600;
letter-spacing: .08em;
text-transform: uppercase;
color: #555;
display: flex;
align-items: center;
gap: 4px;
}
.mwse-streams-grid {
display: flex;
flex-direction: column;
gap: 6px;
padding: 0 8px;
}
/* Tek akış tile'ı */
.mwse-stream-tile {
border-radius: 5px;
overflow: hidden;
background: #1e1e1e;
border: 1px solid #2a2a2a;
position: relative;
}
.mwse-stream-tile__video {
width: 100%;
aspect-ratio: 16/9;
display: block;
background: #000;
object-fit: cover;
}
/* Ses tile'ı (video yok) */
.mwse-stream-tile--audio .mwse-stream-tile__audio-icon {
display: flex;
align-items: center;
justify-content: center;
height: 60px;
font-family: 'Material Icons Round';
font-size: 28px;
color: #0078d4;
background: #0d1e2e;
}
.mwse-stream-tile__info {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: rgba(0,0,0,.5);
font-size: 10px;
}
.mwse-stream-tile__label { color: #ccc; font-weight: 600; flex: 1; }
.mwse-stream-tile__peer { color: #555; }
.mwse-stream-tile__close {
cursor: pointer; color: #444; font-size: 12px;
padding: 1px 3px; border-radius: 2px;
}
.mwse-stream-tile__close:hover { background: #333; color: #ccc; }
/* ── Tek kolon ───────────────────────────────────────────────────────── */
.mwse-col {
min-width: 220px;
max-width: 260px;
@ -66,21 +186,22 @@
border-right: 1px solid #2e2e2e;
background: #1e1e1e;
}
.mwse-col--active { background: #222; }
.mwse-col__header {
padding: 8px 12px 6px;
font-size: 11px;
font-weight: 600;
letter-spacing: .06em;
font-size: 10px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
color: #777;
color: #666;
border-bottom: 1px solid #2a2a2a;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
}
/* Search box */
.mwse-col__search {
margin: 6px 8px;
padding: 4px 8px;
@ -92,10 +213,8 @@
outline: none;
flex-shrink: 0;
}
.mwse-col__search:focus { border-color: #0078d4; }
/* Item list */
.mwse-col__list {
flex: 1;
overflow-y: auto;
@ -103,22 +222,20 @@
scrollbar-width: thin;
scrollbar-color: #333 transparent;
}
.mwse-col__list::-webkit-scrollbar { width: 4px; }
.mwse-col__list::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
/* Single item */
/* ── Liste öğesi ─────────────────────────────────────────────────────── */
.mwse-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
cursor: pointer;
border-radius: 0;
border-left: 2px solid transparent;
transition: background 80ms;
transition: background 70ms;
}
.mwse-item:hover { background: #2a2a2a; }
.mwse-item--active {
background: #0d3a5a !important;
@ -126,13 +243,14 @@
}
.mwse-item__icon {
font-size: 12px;
font-family: 'Material Icons Round';
font-size: 18px;
color: #555;
flex-shrink: 0;
width: 14px;
width: 20px;
text-align: center;
line-height: 1;
}
.mwse-item--active .mwse-item__icon { color: #60cdff; }
.mwse-item__body { flex: 1; min-width: 0; }
@ -144,117 +262,61 @@
color: #d0d0d0;
font-size: 13px;
}
.mwse-item--active .mwse-item__label { color: #fff; }
.mwse-item__meta {
font-size: 11px;
color: #666;
color: #555;
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mwse-item--active .mwse-item__meta { color: #4d9fce; }
.mwse-item__arrow {
font-size: 10px;
color: #444;
font-family: 'Material Icons Round';
font-size: 14px;
color: #3a3a3a;
flex-shrink: 0;
}
.mwse-item--active .mwse-item__arrow { color: #60cdff; }
/* Status badges */
.mwse-badge {
display: inline-block;
padding: 1px 5px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
letter-spacing: .03em;
}
/* ── Alt buton alanı ─────────────────────────────────────────────────── */
.mwse-badge--live { background: #d32f2f; color: #fff; }
.mwse-badge--ok { background: #2e7d32; color: #fff; }
.mwse-badge--ws { background: #333; color: #aaa; }
.mwse-badge--p2p { background: #1565c0; color: #fff; }
/* Progress bar (file transfer) */
.mwse-progress {
margin: 8px 12px;
height: 4px;
background: #333;
border-radius: 2px;
}
.mwse-progress__bar {
height: 100%;
background: #0078d4;
border-radius: 2px;
transition: width 200ms;
}
/* Action buttons inside columns */
.mwse-col__actions {
padding: 8px 12px;
padding: 8px 10px;
border-top: 1px solid #2a2a2a;
display: flex;
gap: 6px;
flex-shrink: 0;
flex-wrap: wrap;
}
.mwse-btn {
flex: 1;
padding: 5px 8px;
background: #2a2a2a;
border: 1px solid #3a3a3a;
background: #252525;
border: 1px solid #333;
border-radius: 4px;
color: #ccc;
color: #bbb;
font-size: 11px;
cursor: pointer;
text-align: center;
}
.mwse-btn:hover { background: #333; color: #fff; }
.mwse-btn--primary { background: #0d47a1; border-color: #1565c0; color: #fff; }
.mwse-btn--primary:hover { background: #1565c0; }
.mwse-btn--danger { background: #4a1a1a; border-color: #7b2020; color: #f88; }
.mwse-btn--danger:hover { background: #7b2020; }
/* Slider for quality controls */
.mwse-slider-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px;
justify-content: center;
gap: 4px;
white-space: nowrap;
}
.mwse-btn:hover { background: #2e2e2e; color: #fff; border-color: #444; }
.mwse-btn--primary { background: #0d47a1; border-color: #1565c0; color: #fff; }
.mwse-btn--primary:hover { background: #1565c0; }
.mwse-btn--danger { background: #3a1010; border-color: #6a2020; color: #f99; }
.mwse-btn--danger:hover { background: #6a2020; }
.mwse-slider-row label {
font-size: 11px;
color: #888;
flex-shrink: 0;
width: 64px;
}
/* ── Gelen istek bildirimi ───────────────────────────────────────────── */
.mwse-slider-row input[type=range] {
flex: 1;
accent-color: #0078d4;
}
.mwse-slider-row span {
font-size: 11px;
color: #bbb;
width: 40px;
text-align: right;
}
/* Video preview thumbnail */
.mwse-preview {
width: 100%;
aspect-ratio: 16/9;
background: #111;
border-top: 1px solid #2a2a2a;
object-fit: contain;
}
/* ---- Gelen istek bildirim alanı ---- */
.mwse-notif-area { flex-shrink: 0; }
.mwse-notif-bar {
@ -262,178 +324,70 @@
align-items: center;
gap: 12px;
padding: 8px 14px;
background: #162016;
border-bottom: 1px solid #2a3a2a;
}
.mwse-notif-bar__msg {
flex: 1;
font-size: 12px;
color: #b8d4b8;
background: #0e1f0e;
border-bottom: 1px solid #1e3a1e;
}
.mwse-notif-bar__msg { flex: 1; font-size: 12px; color: #a8c8a8; }
.mwse-notif-bar__msg code {
font-family: 'Consolas', monospace;
font-size: 11px;
background: #1e2e1e;
padding: 1px 4px;
border-radius: 3px;
color: #90c090;
font-family: 'Consolas', monospace; font-size: 11px;
background: #152515; padding: 1px 5px; border-radius: 3px; color: #7cac7c;
}
.mwse-notif-bar__dot { color: #4caf50; }
.mwse-notif-bar__actions { display: flex; gap: 6px; flex-shrink: 0; }
.mwse-notif-bar__actions .mwse-btn { padding: 4px 14px; font-size: 11px; }
.mwse-notif-bar__dot { color: #4caf50; margin-right: 4px; }
/* ── ID kartı ────────────────────────────────────────────────────────── */
.mwse-notif-bar__actions {
display: flex;
gap: 6px;
flex-shrink: 0;
}
.mwse-notif-bar__actions .mwse-btn {
padding: 4px 14px;
font-size: 11px;
}
/* ---- Benim ID kartı ---- */
.mwse-id-card {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 8px;
background: #252525;
border: 1px solid #333;
border-radius: 4px;
cursor: pointer;
transition: border-color 150ms;
max-width: 260px;
overflow: hidden;
display: flex; align-items: center; gap: 6px;
padding: 4px 10px;
background: #1e1e1e; border: 1px solid #333; border-radius: 5px;
cursor: pointer; transition: border-color 120ms;
}
.mwse-id-card:hover { border-color: #0078d4; }
.mwse-id-card__label {
font-size: 10px;
color: #666;
text-transform: uppercase;
letter-spacing: .05em;
flex-shrink: 0;
}
/* Sanal IP (büyük, belirgin) */
.mwse-id-card__ip {
font-family: 'Consolas', 'Menlo', monospace;
font-size: 13px;
font-weight: 600;
color: #60cdff;
white-space: nowrap;
font-family: 'Consolas', monospace; font-size: 13px;
font-weight: 700; color: #60cdff; white-space: nowrap;
}
.mwse-id-card__ip:empty { display: none; }
.mwse-id-card__ip:empty + .mwse-id-card__value { margin-left: 0; }
/* UUID (küçük, soluk) */
.mwse-id-card__value {
font-family: 'Consolas', 'Menlo', monospace;
font-size: 10px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mwse-id-card__copy {
font-size: 11px;
color: #555;
flex-shrink: 0;
transition: color 150ms;
font-family: 'Consolas', monospace; font-size: 10px;
color: #555; white-space: nowrap;
}
.mwse-id-card__copy { font-size: 14px; color: #444; }
.mwse-id-card:hover .mwse-id-card__copy { color: #0078d4; }
.mwse-id-card--copied {
border-color: #4caf50 !important;
}
.mwse-id-card--copied .mwse-id-card__value { color: #4caf50; }
.mwse-id-card--copied { border-color: #4caf50 !important; }
.mwse-id-card--copied .mwse-id-card__ip,
.mwse-id-card--copied .mwse-id-card__copy { color: #4caf50; }
/* ---- Modal ---- */
/* ── Modal ───────────────────────────────────────────────────────────── */
.mwse-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.65);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
position: fixed; inset: 0; background: rgba(0,0,0,.7);
display: flex; align-items: center; justify-content: center; z-index: 9999;
}
.mwse-modal {
background: #1e1e1e;
border: 1px solid #3a3a3a;
border-radius: 6px;
width: 360px;
max-width: calc(100vw - 32px);
box-shadow: 0 8px 32px rgba(0,0,0,.5);
display: flex;
flex-direction: column;
background: #1e1e1e; border: 1px solid #3a3a3a; border-radius: 6px;
width: 380px; max-width: calc(100vw - 32px);
box-shadow: 0 12px 40px rgba(0,0,0,.6);
}
.mwse-modal__header {
padding: 12px 16px 10px;
border-bottom: 1px solid #2a2a2a;
font-weight: 600;
font-size: 13px;
color: #e0e0e0;
display: flex;
align-items: center;
justify-content: space-between;
}
.mwse-modal__close {
cursor: pointer;
color: #555;
font-size: 16px;
line-height: 1;
padding: 2px 4px;
border-radius: 3px;
padding: 12px 16px 10px; border-bottom: 1px solid #2a2a2a;
font-weight: 600; font-size: 13px; color: #e0e0e0;
display: flex; align-items: center; justify-content: space-between;
}
.mwse-modal__close { cursor: pointer; color: #555; font-size: 16px; padding: 2px 4px; border-radius: 3px; }
.mwse-modal__close:hover { background: #2a2a2a; color: #ccc; }
.mwse-modal__body {
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.mwse-modal__field {
display: flex;
flex-direction: column;
gap: 4px;
}
.mwse-modal__field label {
font-size: 11px;
color: #888;
letter-spacing: .03em;
}
.mwse-modal__body { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
.mwse-modal__field { display: flex; flex-direction: column; gap: 4px; }
.mwse-modal__field label { font-size: 11px; color: #777; }
.mwse-modal__input {
width: 100%;
padding: 7px 10px;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 4px;
color: #d4d4d4;
font-size: 13px;
outline: none;
box-sizing: border-box;
width: 100%; padding: 7px 10px; background: #2a2a2a;
border: 1px solid #3a3a3a; border-radius: 4px; color: #d4d4d4;
font-size: 13px; outline: none; box-sizing: border-box;
}
.mwse-modal__input:focus { border-color: #0078d4; }
.mwse-modal__footer {
padding: 10px 16px 14px;
display: flex;
gap: 8px;
justify-content: flex-end;
}
.mwse-modal__footer .mwse-btn {
flex: none;
padding: 6px 18px;
font-size: 12px;
}
.mwse-modal__footer { padding: 10px 16px 14px; display: flex; gap: 8px; justify-content: flex-end; }
.mwse-modal__footer .mwse-btn { flex: none; padding: 6px 20px; font-size: 12px; }