見出し画像

【大バズリ】NotebookLMスライドをパワポやGIF画像に即変換するCanvas。ん…Canvas!?【コード無料配布】

こんにちは、まじんです。

昨日Xで投稿したこちらのポストがバズってまして。

いや……投稿から1日経ってないのに
まじん式v4 のいいね/インプの半数超えてるんだが…。

しかもこれ、
Geminiと30分くらいお喋りしてたら完成しました。

今回は、このアプリのソースコードを無料公開したいと思います!

また、どのようにGeminiと対話したのか?
そのバイブコーディングのテクニックについては、
メンバーシップ限定エリアに記載しますね、さすがに。

画像

【無料公開】自分のGeminiで再現しよう!

Gemini(思考モード)でCanvasをオンにして、
以下のプロンプトを送信してみてください。無料GeminiでもOK。

以下のコードをCanvasにそのままプレビューしてください。
---
[ソースコード]

ソースコード(Xで公開したこちら

import React, { useState, useEffect, useRef } from 'react';
import { Upload, Download, Loader2, CheckCircle2, X, Film, FileArchive, ScrollText, Settings2, Presentation, Sparkles, FileText, Zap, RefreshCw } from 'lucide-react';

// 外部ライブラリの読み込みヘルパー
const loadScript = (src) => {
  return new Promise((resolve, reject) => {
    if (document.querySelector(`script[src="${src}"]`)) {
      resolve();
      return;
    }
    const script = document.createElement('script');
    script.src = src;
    script.onload = resolve;
    script.onerror = reject;
    document.body.appendChild(script);
  });
};

export default function App() {
  const [isDragging, setIsDragging] = useState(false);
  const [file, setFile] = useState(null);
  const [status, setStatus] = useState('idle'); // idle, processing, zipping, complete, error
  const [progress, setProgress] = useState({ current: 0, total: 0, message: '' });
  const [generatedImages, setGeneratedImages] = useState([]);
  const [zipBlob, setZipBlob] = useState(null);
  const [errorMsg, setErrorMsg] = useState('');
  const [librariesLoaded, setLibrariesLoaded] = useState(false);
  
  const [gifStatus, setGifStatus] = useState('idle'); 
  const [longImageStatus, setLongImageStatus] = useState('idle');
  const [pptxStatus, setPptxStatus] = useState('idle');
  
  const [gifInterval, setGifInterval] = useState(1.0); // 秒単位

  const fileInputRef = useRef(null);
  const cancelProcessingRef = useRef(false);

  useEffect(() => {
    const loadLibs = async () => {
      try {
        await loadScript('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js');
        await loadScript('https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js');
        await loadScript('https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js');
        await loadScript('https://cdn.jsdelivr.net/npm/pptxgenjs@3.12.0/dist/pptxgen.bundle.js');
        
        if (window.pdfjsLib) {
          window.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
        }
        setLibrariesLoaded(true);
      } catch (err) {
        setErrorMsg('ライブラリの読み込みに失敗しました。リロードしてください。');
      }
    };
    loadLibs();
  }, []);

  const handleDragOver = (e) => { e.preventDefault(); setIsDragging(true); };
  const handleDragLeave = (e) => { e.preventDefault(); setIsDragging(false); };
  const handleDrop = (e) => {
    e.preventDefault();
    setIsDragging(false);
    if (e.dataTransfer.files && e.dataTransfer.files[0]) {
      const droppedFile = e.dataTransfer.files[0];
      droppedFile.type === 'application/pdf' ? startProcessing(droppedFile) : setErrorMsg('PDFファイルのみ対応しています。');
    }
  };
  const handleFileSelect = (e) => {
    if (e.target.files && e.target.files[0]) startProcessing(e.target.files[0]);
  };

  const startProcessing = async (selectedFile) => {
    if (!librariesLoaded) return;
    setFile(selectedFile);
    setStatus('processing');
    setGeneratedImages([]);
    setZipBlob(null);
    setErrorMsg('');
    setGifStatus('idle');
    setLongImageStatus('idle');
    setPptxStatus('idle');
    setProgress({ current: 0, total: 0, message: 'Analyzing Structure...' });
    cancelProcessingRef.current = false;

    try {
      const arrayBuffer = await selectedFile.arrayBuffer();
      const pdf = await window.pdfjsLib.getDocument(arrayBuffer).promise;
      const totalPages = pdf.numPages;
      setProgress({ current: 0, total: totalPages, message: `Rendering Slides...` });

      const images = [];
      const zip = new window.JSZip();
      const folder = zip.folder("images");

      for (let i = 1; i <= totalPages; i++) {
        if (cancelProcessingRef.current) break;
        const page = await pdf.getPage(i);
        const scale = 2.0;
        const viewport = page.getViewport({ scale });
        const canvas = document.createElement('canvas');
        const context = canvas.getContext('2d');
        canvas.height = viewport.height;
        canvas.width = viewport.width;

        await page.render({ canvasContext: context, viewport }).promise;

        const blob = await new Promise(resolve => canvas.toBlob(resolve, 'image/png'));
        const dataUrl = canvas.toDataURL('image/png');
        
        images.push({ id: i, dataUrl, name: `slide_${String(i).padStart(2, '0')}.png`, width: viewport.width, height: viewport.height });
        folder.file(`slide_${String(i).padStart(2, '0')}.png`, blob);
        setProgress({ current: i, total: totalPages, message: `${Math.round((i / totalPages) * 100)}% Complete` });
        await new Promise(r => setTimeout(r, 20));
      }

      if (cancelProcessingRef.current) { setStatus('idle'); setFile(null); return; }

      setGeneratedImages(images);
      setStatus('zipping');
      setProgress(prev => ({ ...prev, message: 'Packaging Assets...' }));
      const content = await zip.generateAsync({ type: "blob" });
      setZipBlob(content);
      setStatus('complete');
    } catch (err) {
      console.error(err);
      setErrorMsg('処理中にエラーが発生しました。ファイルを確認してください。');
      setStatus('error');
    }
  };

  const downloadFile = (blob, filename) => {
    const url = URL.createObjectURL(blob);
    const link = document.createElement('a');
    link.href = url;
    link.download = filename;
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
    URL.revokeObjectURL(url);
  };

  const handleDownloadZip = () => zipBlob && file && downloadFile(zipBlob, `${file.name.replace('.pdf', '')}_slides.zip`);

  const generateGif = async () => {
    if (!window.GIF || generatedImages.length === 0) return;
    setGifStatus('processing');
    try {
      const workerResponse = await fetch('https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js');
      const workerBlob = await workerResponse.blob();
      const workerUrl = URL.createObjectURL(workerBlob);
      const gif = new window.GIF({ workers: 2, quality: 10, workerScript: workerUrl, width: generatedImages[0].width, height: generatedImages[0].height });

      for (const imgData of generatedImages) {
        const img = new Image();
        img.src = imgData.dataUrl;
        await new Promise(resolve => { img.complete ? resolve() : img.onload = resolve; });
        gif.addFrame(img, { delay: gifInterval * 1000 });
      }

      gif.on('finished', (blob) => {
        downloadFile(blob, `${file.name.replace('.pdf', '')}_animation.gif`);
        setGifStatus('complete');
        URL.revokeObjectURL(workerUrl);
      });
      gif.render();
    } catch (err) { setGifStatus('error'); }
  };

  const generatePptx = async () => {
    if (!window.PptxGenJS || generatedImages.length === 0) return;
    setPptxStatus('processing');
    try {
      const pptx = new window.PptxGenJS();
      const firstImg = generatedImages[0];
      const ratio = firstImg.width / firstImg.height;
      if (Math.abs(ratio - 16/9) < 0.1) pptx.layout = 'LAYOUT_16x9';
      else if (Math.abs(ratio - 4/3) < 0.1) pptx.layout = 'LAYOUT_4x3';
      else { pptx.defineLayout({ name: 'CUSTOM', width: 10, height: 10 / ratio }); pptx.layout = 'CUSTOM'; }

      for (const imgData of generatedImages) {
        const slide = pptx.addSlide();
        slide.addImage({ data: imgData.dataUrl, x: 0, y: 0, w: '100%', h: '100%' });
      }
      await pptx.writeFile({ fileName: `${file.name.replace('.pdf', '')}.pptx` });
      setPptxStatus('complete');
    } catch (err) { setPptxStatus('error'); }
  };

  const generateLongImage = async () => {
    if (generatedImages.length === 0) return;
    setLongImageStatus('processing');
    try {
      const maxWidth = Math.max(...generatedImages.map(img => img.width));
      const totalHeight = generatedImages.reduce((sum, img) => sum + img.height, 0);
      const canvas = document.createElement('canvas');
      const ctx = canvas.getContext('2d');
      canvas.width = maxWidth;
      canvas.height = totalHeight;
      ctx.fillStyle = '#FFFFFF';
      ctx.fillRect(0, 0, canvas.width, canvas.height);
      let currentY = 0;
      
      for (const imgData of generatedImages) {
        const img = new Image();
        img.src = imgData.dataUrl;
        await new Promise(resolve => { img.complete ? resolve() : img.onload = resolve; });
        const x = (maxWidth - imgData.width) / 2;
        ctx.drawImage(img, x, currentY);
        currentY += imgData.height;
      }
      canvas.toBlob((blob) => {
        if (blob) {
          downloadFile(blob, `${file.name.replace('.pdf', '')}_long.png`);
          setLongImageStatus('complete');
        } else setLongImageStatus('error');
      }, 'image/png');
    } catch (err) { setLongImageStatus('error'); }
  };
  
  const reset = () => {
    setFile(null);
    setStatus('idle');
    setGifStatus('idle');
    setLongImageStatus('idle');
    setPptxStatus('idle');
    setGeneratedImages([]);
    setZipBlob(null);
    setErrorMsg('');
    setProgress({ current: 0, total: 0, message: '' });
    if (fileInputRef.current) fileInputRef.current.value = '';
  };

  const cancel = () => { cancelProcessingRef.current = true; reset(); };
  const progressPercent = progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0;

  return (
    <div className="relative min-h-screen bg-slate-950 text-slate-200 font-sans p-6 flex flex-col items-center justify-center overflow-hidden selection:bg-indigo-500/30 selection:text-indigo-100">
      
      {/* Aurora Background Effects */}
      <div className="fixed inset-0 pointer-events-none">
        <div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-indigo-600/20 rounded-full blur-[100px] animate-aurora-1"></div>
        <div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-cyan-600/20 rounded-full blur-[100px] animate-aurora-2"></div>
        <div className="absolute top-[40%] left-[40%] w-[30%] h-[30%] bg-fuchsia-600/10 rounded-full blur-[100px] animate-aurora-3"></div>
        <div className="absolute inset-0 opacity-[0.03] bg-[url('https://www.transparenttextures.com/patterns/stardust.png')]"></div>
      </div>

      <div className="w-full max-w-5xl space-y-8 relative z-10">
        
        {/* Header Section (Dynamic) */}
        <div className={`text-center space-y-6 pt-4 transition-all duration-500 ${status === 'complete' ? 'mb-4' : ''}`}>
          <div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full bg-white/5 border border-white/10 shadow-[0_0_15px_rgba(255,255,255,0.05)] mb-2 animate-fade-in-up backdrop-blur-md">
            <Sparkles className="w-3.5 h-3.5 text-cyan-300" />
            <span className="text-[11px] uppercase tracking-[0.2em] font-bold text-cyan-300/90">Optimized for NotebookLM</span>
          </div>
          
          {status !== 'complete' && (
            <>
              <h1 className="text-4xl md:text-6xl font-extrabold tracking-tight text-white leading-tight drop-shadow-2xl animate-fade-in-up" style={{animationDelay: '0.1s'}}>
                静止したスライドを、<br/>
                <span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-300 via-indigo-300 to-fuchsia-300">自在なビジュアルへ。</span>
              </h1>
              <p className="text-slate-400 text-lg md:text-xl max-w-2xl mx-auto font-light leading-relaxed animate-fade-in-up" style={{animationDelay: '0.2s'}}>
                PDFを瞬時に<span className="text-indigo-200 font-medium">高解像度で画像化</span>し、PowerPointやGIFに再構築。<br className="hidden md:block"/>
                並べ替えも、シェアも、あなたの思いのままに。
              </p>
            </>
          )}
        </div>

        {/* Glassmorphism Main Card Container */}
        <div className={`transition-all duration-500 ${status === 'complete' ? 'max-w-4xl mx-auto' : ''}`}>
          
          {/* Upload Area */}
          {status === 'idle' && (
            <div className="bg-white/5 backdrop-blur-2xl rounded-[2.5rem] shadow-[0_8px_32px_0_rgba(0,0,0,0.36)] border border-white/10 overflow-hidden relative">
              <div
                onDragOver={handleDragOver}
                onDragLeave={handleDragLeave}
                onDrop={handleDrop}
                className={`
                  relative p-24 text-center cursor-pointer group transition-all duration-500
                  ${isDragging ? 'bg-indigo-500/10' : 'hover:bg-white/5'}
                `}
                onClick={() => fileInputRef.current?.click()}
              >
                <input type="file" ref={fileInputRef} className="hidden" accept="application/pdf" onChange={handleFileSelect} />
                <div className={`absolute inset-6 rounded-[1.5rem] border border-dashed transition-all duration-500 ${isDragging ? 'border-indigo-400/50 bg-indigo-500/5' : 'border-slate-600/50 group-hover:border-slate-500/80'}`} />
                <div className="relative z-10 flex flex-col items-center gap-8">
                  <div className={`
                    w-24 h-24 rounded-3xl flex items-center justify-center shadow-2xl transition-all duration-500 border border-white/10
                    ${isDragging ? 'bg-indigo-500 text-white scale-110 rotate-3 shadow-indigo-500/40' : 'bg-slate-800/50 text-cyan-300 group-hover:scale-105 group-hover:bg-slate-800/80'}
                  `}>
                    <FileText className="w-10 h-10" />
                  </div>
                  <div className="space-y-3">
                    <p className="text-2xl font-bold text-slate-200 group-hover:text-white transition-colors">
                      PDFファイルをここにドロップ
                    </p>
                    <p className="text-sm text-slate-500 group-hover:text-slate-400">
                      または <span className="text-cyan-300 underline decoration-cyan-900 underline-offset-4 group-hover:decoration-cyan-400 transition-all">ファイルを選択</span>
                    </p>
                  </div>
                  {!librariesLoaded && (
                    <div className="inline-flex items-center gap-2 px-4 py-2 bg-black/20 border border-white/5 text-slate-400 text-xs font-medium rounded-full mt-4 backdrop-blur-sm">
                      <Loader2 className="w-3 h-3 animate-spin text-cyan-400" /> System Initializing...
                    </div>
                  )}
                </div>
              </div>
            </div>
          )}

          {/* Processing View */}
          {(status === 'processing' || status === 'zipping') && (
            <div className="bg-white/5 backdrop-blur-2xl rounded-[2.5rem] shadow-[0_8px_32px_0_rgba(0,0,0,0.36)] border border-white/10 overflow-hidden p-24 flex flex-col items-center justify-center min-h-[500px]">
              <div className="relative w-48 h-48 mb-10">
                 <div className="absolute inset-0 bg-indigo-500/20 blur-3xl rounded-full animate-pulse"></div>
                 <svg className="w-full h-full rotate-[-90deg] relative z-10" viewBox="0 0 100 100">
                  <circle className="text-slate-800/50 stroke-current" strokeWidth="2" cx="50" cy="50" r="46" fill="transparent"></circle>
                  <circle 
                    className="text-cyan-400 stroke-current transition-all duration-300 ease-out drop-shadow-[0_0_8px_rgba(34,211,238,0.8)]" 
                    strokeWidth="2" strokeLinecap="round" cx="50" cy="50" r="46" fill="transparent"
                    strokeDasharray={`${289.02 * (progressPercent / 100)} 289.02`}
                  ></circle>
                </svg>
                <div className="absolute inset-0 flex flex-col items-center justify-center z-20">
                  <span className="text-5xl font-black text-white tracking-tighter drop-shadow-lg">{progressPercent}%</span>
                </div>
              </div>
              <h3 className="text-2xl font-bold text-white mb-2 tracking-wide animate-pulse">
                {status === 'zipping' ? 'PACKAGING...' : 'CONVERTING SLIDES...'}
              </h3>
              <p className="text-indigo-200/60 font-mono text-sm mb-10">{progress.message}</p>
              <button onClick={cancel} className="text-slate-500 hover:text-white text-xs tracking-widest font-bold px-8 py-3 rounded-full hover:bg-white/10 border border-transparent hover:border-white/10 transition-all backdrop-blur-md">
                CANCEL
              </button>
            </div>
          )}

          {/* Completion View - New Layout */}
          {status === 'complete' && (
            <div className="flex flex-col items-center w-full animate-fade-in-up">
              
              {/* Compact Header Info */}
              <div className="text-center mb-8 w-full">
                 <div className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-500/10 border border-emerald-500/20 rounded-full text-emerald-400 mb-4 backdrop-blur-md">
                   <CheckCircle2 className="w-4 h-4" />
                   <span className="text-sm font-bold tracking-wide">Ready to Download</span>
                 </div>
                 <h2 className="text-2xl md:text-3xl font-bold text-white mb-2 drop-shadow-lg line-clamp-1 px-4">{file?.name}</h2>
                 <div className="flex items-center justify-center gap-4 text-sm text-slate-400 font-mono">
                    <span className="bg-white/5 px-3 py-1 rounded-md border border-white/5">{generatedImages.length} Slides</span>
                    <span className="bg-white/5 px-3 py-1 rounded-md border border-white/5">{(zipBlob?.size / 1024 / 1024).toFixed(1)} MB</span>
                 </div>
              </div>

              {/* Action Grid (Bento Style) */}
              <div className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full">
                  
                  {/* PNG (ZIP) - Priority */}
                  <button onClick={handleDownloadZip} className="group relative col-span-1 md:col-span-2 p-6 bg-slate-800/40 backdrop-blur-xl border border-white/10 rounded-[1.5rem] hover:bg-slate-700/60 hover:border-cyan-500/30 transition-all duration-300 text-left overflow-hidden shadow-lg hover:shadow-cyan-500/20">
                    <div className="absolute top-0 right-0 p-32 bg-cyan-500/10 rounded-full blur-3xl group-hover:bg-cyan-400/20 transition-all duration-500 translate-x-1/2 -translate-y-1/2"></div>
                    <div className="flex items-start justify-between relative z-10">
                      <div className="space-y-1">
                          <div className="flex items-center gap-3 mb-2">
                             <div className="p-2 bg-cyan-500/20 rounded-lg text-cyan-300 border border-cyan-500/20 shadow-[0_0_15px_rgba(6,182,212,0.3)]">
                                <FileArchive className="w-5 h-5" />
                             </div>
                             <span className="text-[10px] font-bold text-cyan-300/80 uppercase tracking-wider border border-cyan-500/20 px-2 py-0.5 rounded-full bg-cyan-500/5">Recommended</span>
                          </div>
                          <h4 className="text-2xl font-bold text-white group-hover:text-cyan-50 transition-colors">PNG Images (ZIP)</h4>
                          <p className="text-sm text-slate-400 group-hover:text-slate-300">全てのページを個別の高解像度画像として一括保存</p>
                      </div>
                      <div className="w-12 h-12 rounded-full bg-white/5 flex items-center justify-center group-hover:bg-cyan-500 group-hover:text-white transition-all shadow-inner border border-white/5">
                          <Download className="w-6 h-6" />
                      </div>
                    </div>
                  </button>

                  {/* PPTX */}
                  <button onClick={generatePptx} disabled={pptxStatus === 'processing'} className="group relative p-6 bg-slate-800/40 backdrop-blur-xl border border-white/10 rounded-[1.5rem] hover:bg-slate-700/60 hover:border-orange-500/30 transition-all duration-300 text-left overflow-hidden shadow-lg hover:shadow-orange-500/20 disabled:opacity-50">
                      <div className="absolute bottom-0 left-0 p-20 bg-orange-500/10 rounded-full blur-3xl group-hover:bg-orange-400/20 transition-all duration-500 -translate-x-1/3 translate-y-1/3"></div>
                      <div className="flex flex-col h-full justify-between relative z-10">
                         <div className="flex justify-between items-start mb-4">
                            <div className="p-2 bg-orange-500/20 rounded-lg text-orange-300 border border-orange-500/20 shadow-[0_0_15px_rgba(249,115,22,0.3)]">
                              {pptxStatus === 'processing' ? <Loader2 className="w-5 h-5 animate-spin" /> : <Presentation className="w-5 h-5" />}
                            </div>
                            {pptxStatus === 'complete' ? 
                              <div className="bg-emerald-500/20 text-emerald-400 p-1.5 rounded-full border border-emerald-500/20"><CheckCircle2 className="w-4 h-4" /></div> 
                              : <div className="bg-white/5 p-1.5 rounded-full group-hover:bg-orange-500/20 group-hover:text-orange-300 transition-colors"><Download className="w-4 h-4 text-slate-500" /></div>
                            }
                         </div>
                         <div>
                            <h4 className="text-lg font-bold text-white mb-0.5 group-hover:text-orange-50 transition-colors">PowerPoint</h4>
                            <p className="text-[11px] text-slate-400">画像貼り付け済みスライド (PPTX)</p>
                         </div>
                      </div>
                  </button>

                  {/* GIF & Long PNG Cell */}
                  <div className="flex flex-col gap-4">
                      {/* GIF */}
                      <div className="group relative p-4 bg-slate-800/40 backdrop-blur-xl border border-white/10 rounded-[1.5rem] hover:bg-slate-700/60 hover:border-fuchsia-500/30 transition-all duration-300 overflow-hidden shadow-lg hover:shadow-fuchsia-500/20 flex items-center justify-between">
                         <div className="flex items-center gap-3 relative z-10">
                            <div className="p-2 bg-fuchsia-500/20 rounded-lg text-fuchsia-300 border border-fuchsia-500/20 shadow-[0_0_15px_rgba(217,70,239,0.3)]">
                               {gifStatus === 'processing' ? <Loader2 className="w-4 h-4 animate-spin" /> : <Film className="w-4 h-4" />}
                            </div>
                            <div>
                               <h4 className="text-sm font-bold text-white group-hover:text-fuchsia-50 transition-colors">GIF Animation</h4>
                               <div className="flex items-center gap-2 mt-0.5">
                                   <select 
                                     value={gifInterval}
                                     onChange={(e) => setGifInterval(Number(e.target.value))}
                                     disabled={gifStatus === 'processing'}
                                     className="text-[10px] bg-black/30 border border-white/10 rounded px-1.5 py-0.5 text-slate-300 focus:outline-none hover:bg-black/50 cursor-pointer"
                                   >
                                      <option value={0.5}>0.5s</option>
                                      <option value={1.0}>1.0s</option>
                                      <option value={2.0}>2.0s</option>
                                   </select>
                               </div>
                            </div>
                         </div>
                         <button onClick={generateGif} disabled={gifStatus === 'processing'} className="relative z-10 text-[10px] font-bold bg-white/5 text-slate-300 border border-white/5 px-4 py-2 rounded-full hover:bg-fuchsia-500 hover:text-white hover:border-fuchsia-400 transition-all shadow-sm">
                            {gifStatus === 'complete' ? 'RETRY' : 'CREATE'}
                         </button>
                      </div>

                      {/* Long PNG */}
                      <button onClick={generateLongImage} disabled={longImageStatus === 'processing'} className="group relative p-4 bg-slate-800/40 backdrop-blur-xl border border-white/10 rounded-[1.5rem] hover:bg-slate-700/60 hover:border-blue-500/30 transition-all duration-300 overflow-hidden shadow-lg hover:shadow-blue-500/20 flex items-center justify-between text-left disabled:opacity-50 flex-1">
                         <div className="flex items-center gap-3 relative z-10">
                            <div className="p-2 bg-blue-500/20 rounded-lg text-blue-300 border border-blue-500/20 shadow-[0_0_15px_rgba(59,130,246,0.3)]">
                               {longImageStatus === 'processing' ? <Loader2 className="w-4 h-4 animate-spin" /> : <ScrollText className="w-4 h-4" />}
                            </div>
                            <div>
                               <h4 className="text-sm font-bold text-white group-hover:text-blue-50 transition-colors">Long PNG</h4>
                               <p className="text-[10px] text-slate-400">縦読みスクロール</p>
                            </div>
                         </div>
                         <div className="relative z-10">
                            {longImageStatus === 'complete' ? 
                              <div className="bg-emerald-500/20 text-emerald-400 p-1 rounded-full"><CheckCircle2 className="w-4 h-4" /></div>
                              : <div className="bg-white/5 p-1.5 rounded-full group-hover:bg-blue-500/20 group-hover:text-blue-300 transition-colors"><Download className="w-4 h-4 text-slate-500" /></div>
                            }
                         </div>
                      </button>
                  </div>

              </div>
              
              <button 
                onClick={reset}
                className="mt-8 text-slate-500 hover:text-white flex items-center gap-2 text-xs font-bold tracking-widest uppercase hover:underline decoration-slate-600 underline-offset-4 transition-colors relative z-10"
              >
                <RefreshCw className="w-3.5 h-3.5" /> Start Over
              </button>

            </div>
          )}

          {/* Error View */}
          {status === 'error' && (
            <div className="bg-white/5 backdrop-blur-2xl rounded-[2.5rem] shadow-[0_8px_32px_0_rgba(0,0,0,0.36)] border border-white/10 overflow-hidden p-20 text-center space-y-6">
              <div className="w-20 h-20 bg-red-900/20 border border-red-500/20 text-red-500 rounded-3xl flex items-center justify-center mx-auto mb-4 shadow-[0_0_30px_rgba(239,68,68,0.1)] backdrop-blur-md">
                <X className="w-10 h-10" />
              </div>
              <h3 className="text-xl font-bold text-slate-200">System Error</h3>
              <p className="text-slate-500 max-w-md mx-auto">{errorMsg}</p>
              <button onClick={reset} className="px-8 py-3 bg-white/10 text-white border border-white/10 rounded-xl hover:bg-white/20 transition-all font-medium shadow-lg backdrop-blur-md">
                RETRY
              </button>
            </div>
          )}
        </div>
        
        {/* Footer info */}
        <div className="flex flex-col items-center justify-center space-y-3 pt-8 pb-4 opacity-40 hover:opacity-100 transition-opacity">
           <div className="flex items-center gap-3 text-[10px] font-bold text-slate-500 tracking-[0.2em] uppercase">
              <div className="flex items-center gap-1">
                 <Zap className="w-3 h-3" />
                 <span>Client-Side Processing</span>
              </div>
              <span className="w-1 h-1 rounded-full bg-slate-600"></span>
              <span>No Uploads</span>
           </div>
           <p className="text-xs font-medium text-slate-600">
             Developed by <span className="text-cyan-600/80">majin</span>
           </p>
        </div>

      </div>
      
      {/* Global Styles for Animations & Aurora */}
      <style>{`
        @keyframes aurora-1 {
          0%, 100% { transform: translate(0, 0) scale(1); }
          50% { transform: translate(20px, -20px) scale(1.1); }
        }
        @keyframes aurora-2 {
          0%, 100% { transform: translate(0, 0) scale(1); }
          50% { transform: translate(-20px, 10px) scale(1.2); }
        }
        @keyframes aurora-3 {
          0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.3; }
          50% { transform: translate(10px, 10px) scale(0.9); opacity: 0.6; }
        }
        .animate-aurora-1 { animation: aurora-1 10s infinite ease-in-out; }
        .animate-aurora-2 { animation: aurora-2 12s infinite ease-in-out; }
        .animate-aurora-3 { animation: aurora-3 8s infinite ease-in-out; }

        @keyframes fade-in-up {
          from { opacity: 0; transform: translateY(20px); }
          to { opacity: 1; transform: translateY(0); }
        }
        .animate-fade-in-up {
          animation: fade-in-up 0.8s cubic-bezier(0.2, 0.8, 0.2, 1) forwards;
        }
      `}</style>
    </div>
  );
}

こんな感じでプレビューされたら成功です。
後は社員やお友達にリンクを共有すればOK!

画像

【メンバーシップ限定】バイブコーディングで作ってみよう!

これはバイブコーディングで30分くらいで完成したのですが、
具体的にどんな指示をGeminiに投げたのか、解説していきます。

超有益なプロンプトテクニックも紹介しておりますので、
気になる方はnoteメンバーシップへの加入をぜひ。

メンバーシップはこちら👇️

さて、それでは第一声からいきますよ!

ここから先は

25,389字 / 12画像

抽選でnoteポイント最大100%還元 1/14まで

メンバーシップ ¥ 980 /月

Google Workspace × Gemini 活用の研究コミュニティ 「Majincraft(…

PayPay決済で追加チャンス!12/28まで

マジクラ メンバーシップ(1期)

¥980 / 月

この記事が気に入ったらチップで応援してみませんか?

『第2回 AI Agent Hackathon with Google Cloud』で準優勝。 【 Google Workspace × 生成AI 】をテーマに業務効率化を研究してます!
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1