Javascriptの課題チェック

プログラミングの授業で、Javascriptを教えているのですが、学生が提出したHTMLファイルをチェックするのが大変です。

index.htmlをつくって、次々と開けるようにしているのですが、時にはソースコードをみたりする必要があり、いまいち使い勝手がよくありませんでした。

AI (Gemini 3.5 Flash)に作ってもらったのが以下のコード。

JSView.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Javascriptチェック</title>
    <style>
        body { font-family: sans-serif; margin: 0; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
        /* タブエリア */
        #tabs { display: flex; background: #2c3e50; padding: 5px 5px 0 5px; gap: 2px; overflow-x: auto; }
        .tab { padding: 10px 18px; color: #ecf0f1; background: #34495e; cursor: pointer; border-radius: 4px 4px 0 0; white-space: nowrap; }
        .tab.active { background: #fff; color: #2c3e50; font-weight: bold; }
        
        /* メインコンテンツエリア */
        #main { display: flex; flex: 1; overflow: hidden; }
        
        /* 左側カラム構造 */
        #leftcolumn { width: 250px; background: #f8f9fa; border-right: 1px solid #dee2e6; display: flex; flex-direction: column; }
        
        /* 学生切り替えコントローラー */
        #student_controller { padding: 10px; background: #fff; border-bottom: 1px solid #dee2e6; display: flex; gap: 6px; }
        .nav-btn { flex: 1; padding: 8px 4px; text-align: center; background: #34495e; color: #fff; font-size: 12px; font-weight: bold; border-radius: 4px; cursor: pointer; user-select: none; white-space: nowrap; }
        .nav-btn:hover { background: #2c3e50; }
        .nav-btn.disabled { background: #ced4da; color: #6c757d; cursor: not-allowed; }
        
        /* Excelコピー専用ボタンの個別スタイル */
        #copy_list_btn { background: #217346; color: #fff; } /* Excelをイメージしたグリーン */
        #copy_list_btn:hover { background: #154a2d; }
        #copy_list_btn.success { background: #28a745; } /* コピー成功時のエフェクト */
        
        /* 左側:学籍番号リスト */
        #sidebar { flex: 1; overflow-y: auto; padding: 10px; }
        .student-item { padding: 8px; margin-bottom: 6px; background: #fff; cursor: pointer; border: 1px solid #ced4da; border-radius: 4px; position: relative; }
        .student-item:hover { background: #e9ecef; }
        .student-item.active { background: #0078d7; color: #fff; }
        
        /* 不正疑惑の警告スタイル */
        .student-item.alert { border: 2px solid #dc3545; background: #fff5f5; }
        .student-item.alert:hover { background: #ffe3e3; }
        .student-item.active.alert { background: #dc3545; color: #fff; }
        .badge { display: inline-block; background: #dc3545; color: white; padding: 2px 6px; font-size: 11px; border-radius: 3px; font-weight: bold; margin-top: 3px; }
        .active .badge { background: white; color: #dc3545; }
        
        /* 右側:全体表示エリア */
        #viewer-container { flex: 1; display: flex; flex-direction: column; background: #fff; }
        #file-info { padding: 12px; background: #f1f3f5; border-bottom: 1px solid #dee2e6; font-size: 14px; line-height: 1.4; color: #333; }
        
        /* 左右分割用のビューエリア */
        #view-area { flex: 1; display: flex; overflow: hidden; background: #fdfdfd; }
        
        /* ビューエリア左(プレビュー表示側) */
        .view-left { flex: 1; width: 50%; border-right: 2px solid #dee2e6; display: flex; justify-content: center; align-items: center; overflow: auto; background: #fff; }
        #viewer { border: none; width: 100%; height: 100%; }
        #img-viewer { max-width: 100%; max-height: 100%; object-fit: contain; display: block; margin: auto; }
        
        /* ビューエリア右(ソースコード表示側) */
        .view-right { flex: 1; width: 50%; display: flex; flex-direction: column; background: #fff; overflow: hidden; }
        .code-header { background: #2c3e50; color: #fff; padding: 8px 12px; font-size: 12px; font-weight: bold; }
        #viewer-source { border: none; width: 100%; flex: 1; background: #fafafa; }
        .no-code-message { padding: 20px; color: #777; font-style: italic; text-align: center; }
    </style>
</head>
<body>

<div id="tabs"></div>
<div id="main">
    <div id="leftcolumn">
        <div id="student_controller">
            <div id="prev_btn" class="nav-btn disabled" onclick="navigateStudent(-1)">前へ</div>
            <div id="next_btn" class="nav-btn disabled" onclick="navigateStudent(1)">次へ</div>
            <div id="copy_list_btn" class="nav-btn" onclick="copyStudentIdsToClipboard()">名簿コピー</div>
        </div>
        <div id="sidebar">← 左上のタブから回数を選択してください</div>
    </div>
    <div id="viewer-container">
        <div id="file-info">学生のファイルを選択すると、ここに中身が表示されます。</div>
        
        <div id="view-area">
            <div class="view-left">
                <iframe id="viewer" style="display:none;"></iframe>
                <img id="img-viewer" style="display:none;">
            </div>
            
            <div class="view-right">
                <div class="code-header">📁 ソースコード(プレーンテキスト表示)</div>
                <iframe id="viewer-source" style="display:none;"></iframe>
                <div id="source-fallback" class="no-code-message">学生を選択するとソースコードがここに表示されます</div>
            </div>
        </div>
    </div>
</div>

<script>
const totalData = {};
const hashMap = {};
const existingFolders = [];
let currentStudentList = [];
let currentStudentIndex = -1;

function registerList(listData) {
    if (!Array.isArray(listData) || listData.length === 0) return;

    const firstPath = listData[0][0].replace(/^\.\//, '');
    const folder = firstPath.split('/')[0];

    if (!totalData[folder]) {
        totalData[folder] = [];
        existingFolders.push(folder);
    }

    listData.forEach(item => {
        const path = item[0];
        const hash = item[1];
        if (!path) return;

        const cleanPath = path.replace(/^\.\//, '');
        const parts = cleanPath.split('/');
        const filename = parts[1];
        const id = filename.substring(0, 7); // 学籍番号

        const studentObj = { id: id, filename: filename, fullpath: cleanPath, hash: hash, isCopy: false, copyWith: [] };
        totalData[folder].push(studentObj);

        if (!hashMap[hash]) {
            hashMap[hash] = [];
        }
        hashMap[hash].push(studentObj);
    });
}

for (let i = 1; i <= 15; i++) {
    const num = String(i).padStart(2, '0');
    const script = document.createElement('script');
    script.src = `list${num}.js`;
    script.onerror = () => { };
    document.head.appendChild(script);
}

window.addEventListener('load', () => {
    setTimeout(initializeSystem, 500);
});

function initializeSystem() {
    Object.keys(hashMap).forEach(hash => {
        const list = hashMap[hash];
        if (list.length > 1) {
            list.forEach(target => {
                target.isCopy = true;
                list.forEach(other => {
                    if (target.fullpath !== other.fullpath) {
                        target.copyWith.push(other.fullpath);
                    }
                });
            });
        }
    });

    existingFolders.sort();

    const tabsContainer = document.getElementById('tabs');
    tabsContainer.innerHTML = ''; 
    
    if (existingFolders.length === 0) {
        tabsContainer.innerHTML = '<div style="color:white; padding:10px;">listxx.js ファイルが一つも見つかりません。</div>';
        return;
    }

    existingFolders.forEach(folder => {
        const tab = document.createElement('div');
        tab.className = 'tab';
        tab.innerText = `${folder}回 (${totalData[folder].length}件)`;
        tab.id = `tab_${folder}`;
        tab.onclick = () => selectTab(folder, tab);
        tabsContainer.appendChild(tab);
    });

    if (existingFolders.length > 0) {
        const latestFolder = existingFolders[existingFolders.length - 1];
        const latestTabEl = document.getElementById(`tab_${latestFolder}`);
        if (latestTabEl) latestTabEl.click();
    }
}

function selectTab(folder, tabEl) {
    document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
    tabEl.classList.add('active');

    const sidebar = document.getElementById('sidebar');
    sidebar.innerHTML = '';

    // ★ここで学籍番号順にソートされています
    totalData[folder].sort((a, b) => a.id.localeCompare(b.id));
    
    currentStudentList = totalData[folder];
    currentStudentIndex = -1;
    updateControllerButtons();

    currentStudentList.forEach((student, index) => {
        const item = document.createElement('div');
        item.className = 'student-item';
        item.id = `student_item_${index}`;
        
        const ext = student.filename.split('.').pop();
        item.innerHTML = `<div><strong>${student.id}</strong> <small style="color:#666;">(${ext})</small></div>`;
        
        if (student.isCopy) {
            item.classList.add('alert');
            item.innerHTML += `<span class="badge">⚠️コピペ疑惑 (${student.copyWith.length}件と一致)</span>`;
        }

        item.onclick = () => selectStudent(student, index);
        sidebar.appendChild(item);
    });
}

function selectStudent(student, index) {
    currentStudentIndex = index;
    
    document.querySelectorAll('.student-item').forEach(i => i.classList.remove('active'));
    const activeItem = document.getElementById(`student_item_${index}`);
    if (activeItem) {
        activeItem.classList.add('active');
        activeItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
    }

    updateControllerButtons();

    let infoHtml = `<strong>ファイルパス:</strong> ${student.fullpath}<br><strong>MD5ハッシュ:</strong> <code>${student.hash}</code>`;
    if (student.isCopy) {
        infoHtml += `<br><span style="color:#dc3545; font-weight:bold;">⚠️不正検出(内容が以下のファイルと完全一致しています):</span><br><small style="color:#dc3545;">${student.copyWith.join('<br>')}</small>`;
    }
    document.getElementById('file-info').innerHTML = infoHtml;

    const iframe = document.getElementById('viewer');
    const img = document.getElementById('img-viewer');
    const iframeSource = document.getElementById('viewer-source');
    const sourceFallback = document.getElementById('source-fallback');
    const ext = student.filename.split('.').pop().toLowerCase();

    iframe.style.display = 'none';
    img.style.display = 'none';
    iframeSource.style.display = 'none';
    sourceFallback.style.display = 'none';
    iframe.src = 'about:blank';
    iframeSource.src = 'about:blank';

    if (['png', 'jpg', 'jpeg', 'gif'].includes(ext)) {
        img.src = student.fullpath;
        img.style.display = 'block';
        sourceFallback.textContent = '画像ファイルのためソースコードはありません';
        sourceFallback.style.display = 'block';
    } else if (['html', 'htm', 'txt', 'js'].includes(ext)) {
        iframe.src = student.fullpath;
        iframe.style.display = 'block';
        
        iframeSource.src = student.fullpath + '.txt';
        iframeSource.style.display = 'block';
    } else {
        iframe.style.display = 'block';
        sourceFallback.innerHTML = `直接展開できない形式(.${ext})です。`;
        sourceFallback.style.display = 'block';
    }
}

function navigateStudent(direction) {
    const nextIndex = currentStudentIndex + direction;
    if (nextIndex >= 0 && nextIndex < currentStudentList.length) {
        selectStudent(currentStudentList[nextIndex], nextIndex);
    }
}

function updateControllerButtons() {
    const prevBtn = document.getElementById('prev_btn');
    const nextBtn = document.getElementById('next_btn');

    if (currentStudentIndex > 0) {
        prevBtn.classList.remove('disabled');
    } else {
        prevBtn.classList.add('disabled');
    }

    if (currentStudentIndex >= 0 && currentStudentIndex < currentStudentList.length - 1) {
        nextBtn.classList.remove('disabled');
    } else {
        nextBtn.classList.add('disabled');
    }
}

// ★【新規機能】学籍番号一覧をクリップボードに一括コピーする関数
function copyStudentIdsToClipboard() {
    if (currentStudentList.length === 0) {
        alert("コピーする学生データがありません。タブを選択してください。");
        return;
    }

    // 学籍番号だけを取り出して改行(\n)で連結
    const idListText = currentStudentList.map(student => student.id).join("\n");

    // クリップボードに書き込み
    navigator.clipboard.writeText(idListText).then(() => {
        const btn = document.getElementById('copy_list_btn');
        const originalText = btn.innerText;
        
        // 成功したことが視覚的にわかるように1.5秒間ボタン変化
        btn.innerText = "コピー完了!";
        btn.classList.add('success');
        
        setTimeout(() => {
            btn.innerText = originalText;
            btn.classList.remove('success');
        }, 1500);
    }).catch(err => {
        alert("コピーに失敗しました: " + err);
    });
}
</script>
</body>
</html>

スクリーンショット

実際のもの

使い方

  1. 学生が提出したファイルを所定のフォルダにいれる。
  2. 次のシェルスクリプトを動かして、中のファイルの一覧を取得(list10.jsを作成)。
    IN=$1
    OUTPUT="list$IN.js"
    
    echo "registerList([" > $OUTPUT
    
    # フォルダ内のファイルに対してmd5sumを実行(隠しファイルやディレクトリは除外)
    find "$IN" -maxdepth 1 -type f ! -name "*.txt" | while read -r filepath; do
    
        # ソース表示用に、拡張子 .txt を付加したコピーファイルを作成
        ln "$filepath" "${filepath}.txt" 2>/dev/null
    
        # md5sumを取得し、ハッシュ値とファイル名に分解
        # 例: d41d8cd98f00b204e9800998ecf8427e  01/24X1001_xxx.html
        md5_res=$(md5sum "$filepath")
        hash=$(echo "$md5_res" | awk '{print $1}')
    
        # JSON/JavaScriptの配列形式で出力
        echo "  [\"$filepath\", \"$hash\"]," | tee -a $OUTPUT
    done
    
    echo "]);" >> $OUTPUT
    
  3. ViewJS.htmlを開く。

AIは人を感動させることができます。