Private
Public Access
1
0

Initial commit: Outlook Lite code app

This commit is contained in:
2026-04-14 18:46:05 +00:00
commit 8ab452cfd4
2431 changed files with 990153 additions and 0 deletions

296
src/App.tsx Normal file
View File

@@ -0,0 +1,296 @@
import { useState, useEffect, useCallback } from 'react';
import { outlookService } from './services/OutlookService';
import type { Email, Folder } from './types';
const FOLDERS: Folder[] = [
{ id: 'inbox', name: 'Bandeja de entrada', icon: '📥', unreadCount: 4 },
{ id: 'sent', name: 'Enviados', icon: '📤', unreadCount: 0 },
{ id: 'drafts', name: 'Borradores', icon: '📝', unreadCount: 1 },
{ id: 'trash', name: 'Papelera', icon: '🗑️', unreadCount: 0 },
];
function formatTime(iso: string): string {
const d = new Date(iso);
const now = new Date();
const diffMs = now.getTime() - d.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Ahora';
if (diffMins < 60) return `${diffMins}m`;
const diffHrs = Math.floor(diffMins / 60);
if (diffHrs < 24) return `${diffHrs}h`;
return d.toLocaleDateString('es-ES', { day: 'numeric', month: 'short' });
}
function getInitials(name: string): string {
return name
.split(' ')
.map((n) => n[0])
.slice(0, 2)
.join('')
.toUpperCase();
}
export default function App() {
const [activeFolder, setActiveFolder] = useState<string>('inbox');
const [emails, setEmails] = useState<Email[]>([]);
const [selectedEmail, setSelectedEmail] = useState<Email | null>(null);
const [loading, setLoading] = useState(false);
const [showCompose, setShowCompose] = useState(false);
const [composeTo, setComposeTo] = useState('');
const [composeSubject, setComposeSubject] = useState('');
const [composeBody, setComposeBody] = useState('');
const [sending, setSending] = useState(false);
const [search, setSearch] = useState('');
const loadEmails = useCallback(async () => {
setLoading(true);
try {
const msgs = await outlookService.getMessages(activeFolder);
setEmails(msgs);
setSelectedEmail(null);
} finally {
setLoading(false);
}
}, [activeFolder]);
useEffect(() => {
loadEmails();
}, [loadEmails]);
const handleSelectEmail = async (email: Email) => {
setSelectedEmail(email);
if (!email.isRead) {
await outlookService.markAsRead(email.id);
setEmails((prev) =>
prev.map((e) => (e.id === email.id ? { ...e, isRead: true } : e))
);
}
};
const handleSend = async () => {
if (!composeTo || !composeSubject) return;
setSending(true);
try {
await outlookService.sendMessage({
to: composeTo,
subject: composeSubject,
body: composeBody,
});
setShowCompose(false);
setComposeTo('');
setComposeSubject('');
setComposeBody('');
} finally {
setSending(false);
}
};
const filteredEmails = emails.filter(
(e) =>
e.subject.toLowerCase().includes(search.toLowerCase()) ||
e.fromName.toLowerCase().includes(search.toLowerCase())
);
const activeFolderData = FOLDERS.find((f) => f.id === activeFolder)!;
return (
<div className="app">
{/* Sidebar */}
<div className="sidebar">
<div className="sidebar-icon" title="Outlook Lite">
📧
</div>
<div
className="sidebar-icon"
title="Buscar"
onClick={() => setSearch('')}
>
🔍
</div>
<div
className="sidebar-icon"
title="Redactar"
onClick={() => setShowCompose(true)}
>
</div>
</div>
<div className="main-area">
{/* Toolbar */}
<div className="toolbar">
<button
className="toolbar-btn primary"
onClick={() => setShowCompose(true)}
>
Redactar
</button>
<button className="toolbar-btn" onClick={loadEmails}>
🔄 Recargar
</button>
<div className="search-box">
<span>🔍</span>
<input
placeholder="Buscar en correos..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{/* Content */}
<div className="content">
{/* Folder list */}
<div className="folder-list">
{FOLDERS.map((folder) => (
<div
key={folder.id}
className={`folder-item ${activeFolder === folder.id ? 'active' : ''}`}
onClick={() => setActiveFolder(folder.id)}
>
<span className="folder-icon">{folder.icon}</span>
<span>{folder.name}</span>
{folder.unreadCount > 0 && (
<span className="unread">{folder.unreadCount}</span>
)}
</div>
))}
</div>
{/* Email list */}
<div className="email-list">
{loading ? (
<div className="loading">
<div className="spinner" />
<span>Cargando...</span>
</div>
) : filteredEmails.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon">📭</div>
<div className="empty-state-text">No hay correos en {activeFolderData.name}</div>
</div>
) : (
filteredEmails.map((email) => (
<div
key={email.id}
className={`email-item ${!email.isRead ? 'unread' : ''} ${selectedEmail?.id === email.id ? 'selected' : ''}`}
onClick={() => handleSelectEmail(email)}
>
<div className="email-from">
{email.fromName}
<span className="email-time">{formatTime(email.receivedAt)}</span>
</div>
<div className="email-subject">{email.subject}</div>
<div className="email-preview">
{email.body.substring(0, 60)}
</div>
</div>
))
)}
</div>
{/* Email detail */}
{selectedEmail ? (
<div className="email-detail">
<div className="email-detail-header">
<div className="email-avatar">
{getInitials(selectedEmail.fromName)}
</div>
<div className="email-meta">
<div className="email-meta-from">{selectedEmail.fromName}</div>
<div className="email-meta-subject">{selectedEmail.subject}</div>
<div className="email-meta-time">
{new Date(selectedEmail.receivedAt).toLocaleString('es-ES', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</div>
</div>
</div>
<div className="email-body">{selectedEmail.body}</div>
<div className="email-actions">
<button
className="toolbar-btn"
onClick={() => {
setComposeTo(selectedEmail.from);
setComposeSubject('Re: ' + selectedEmail.subject);
setShowCompose(true);
}}
>
Responder
</button>
<button
className="toolbar-btn"
onClick={() => {
setComposeTo(selectedEmail.from);
setComposeSubject(selectedEmail.subject);
setShowCompose(true);
}}
>
Reenviar
</button>
</div>
</div>
) : (
<div className="empty-state">
<div className="empty-state-icon">📧</div>
<div className="empty-state-text">Selecciona un correo para leerlo</div>
</div>
)}
</div>
</div>
{/* Compose modal */}
{showCompose && (
<div className="compose-overlay" onClick={() => setShowCompose(false)}>
<div className="compose" onClick={(e) => e.stopPropagation()}>
<div className="compose-header">
<span>Nuevo mensaje</span>
<button className="compose-close" onClick={() => setShowCompose(false)}>
</button>
</div>
<div className="compose-field">
<span className="compose-field-label">Para</span>
<input
placeholder="destinatario@ejemplo.com"
value={composeTo}
onChange={(e) => setComposeTo(e.target.value)}
/>
</div>
<div className="compose-field">
<span className="compose-field-label">Asunto</span>
<input
placeholder="Asunto del mensaje"
value={composeSubject}
onChange={(e) => setComposeSubject(e.target.value)}
/>
</div>
<div className="compose-body">
<textarea
placeholder="Escribe tu mensaje aquí..."
value={composeBody}
onChange={(e) => setComposeBody(e.target.value)}
/>
</div>
<div className="compose-footer">
<button
className="toolbar-btn primary"
onClick={handleSend}
disabled={sending || !composeTo || !composeSubject}
>
{sending ? 'Enviando...' : '📤 Enviar'}
</button>
<button className="toolbar-btn" onClick={() => setShowCompose(false)}>
Descartar
</button>
</div>
</div>
</div>
)}
</div>
);
}

444
src/index.css Normal file
View File

@@ -0,0 +1,444 @@
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-surface: #0f3460;
--accent: #e94560;
--accent-hover: #ff6b6b;
--text-primary: #eaeaea;
--text-secondary: #a0a0b0;
--border: #2a2a4a;
--selected: #1e3a5f;
}
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
height: 100vh;
overflow: hidden;
}
.app {
display: flex;
height: 100vh;
}
/* Sidebar */
.sidebar {
width: 60px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
align-items: center;
padding: 12px 0;
gap: 8px;
}
.sidebar-icon {
width: 40px;
height: 40px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s;
font-size: 18px;
}
.sidebar-icon:hover {
background: var(--bg-surface);
}
.sidebar-icon.active {
background: var(--accent);
}
/* Main content area */
.main-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Toolbar */
.toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.toolbar-btn {
padding: 6px 12px;
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-primary);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.toolbar-btn:hover {
background: var(--selected);
border-color: var(--accent);
}
.toolbar-btn.primary {
background: var(--accent);
border-color: var(--accent);
}
.toolbar-btn.primary:hover {
background: var(--accent-hover);
}
.search-box {
flex: 1;
display: flex;
align-items: center;
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 12px;
gap: 8px;
}
.search-box input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-primary);
font-size: 13px;
}
.search-box input::placeholder {
color: var(--text-secondary);
}
/* Content area with folder list + email list + email detail */
.content {
flex: 1;
display: flex;
overflow: hidden;
}
/* Folder list */
.folder-list {
width: 180px;
background: var(--bg-secondary);
border-right: 1px solid var(--border);
overflow-y: auto;
padding: 8px 0;
}
.folder-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
}
.folder-item:hover {
background: var(--bg-surface);
color: var(--text-primary);
}
.folder-item.active {
background: var(--selected);
color: var(--text-primary);
border-right: 2px solid var(--accent);
}
.folder-item .folder-icon {
font-size: 16px;
}
.folder-item .unread {
margin-left: auto;
background: var(--accent);
color: white;
font-size: 11px;
padding: 1px 6px;
border-radius: 10px;
}
/* Email list */
.email-list {
width: 320px;
border-right: 1px solid var(--border);
overflow-y: auto;
}
.email-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
cursor: pointer;
transition: background 0.15s;
}
.email-item:hover {
background: var(--bg-surface);
}
.email-item.selected {
background: var(--selected);
}
.email-item.unread {
border-left: 3px solid var(--accent);
}
.email-from {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
}
.email-item.unread .email-from {
color: var(--accent);
}
.email-subject {
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.email-preview {
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.email-time {
font-size: 11px;
color: var(--text-secondary);
margin-left: auto;
}
/* Email detail */
.email-detail {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 20px 24px;
gap: 16px;
}
.email-detail-header {
display: flex;
align-items: flex-start;
gap: 12px;
}
.email-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 16px;
flex-shrink: 0;
}
.email-meta {
flex: 1;
min-width: 0;
}
.email-meta-from {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.email-meta-subject {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-top: 4px;
}
.email-meta-time {
font-size: 12px;
color: var(--text-secondary);
margin-top: 2px;
}
.email-body {
font-size: 14px;
line-height: 1.7;
color: var(--text-primary);
white-space: pre-wrap;
}
.email-actions {
display: flex;
gap: 8px;
border-top: 1px solid var(--border);
padding-top: 16px;
}
/* Empty state */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-secondary);
}
.empty-state-icon {
font-size: 48px;
opacity: 0.3;
}
.empty-state-text {
font-size: 14px;
}
/* Compose modal */
.compose-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.compose {
width: 600px;
background: var(--bg-secondary);
border-radius: 8px;
border: 1px solid var(--border);
display: flex;
flex-direction: column;
max-height: 80vh;
}
.compose-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
font-size: 14px;
font-weight: 600;
}
.compose-close {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 18px;
}
.compose-close:hover {
color: var(--text-primary);
}
.compose-field {
display: flex;
align-items: center;
border-bottom: 1px solid var(--border);
padding: 8px 16px;
gap: 8px;
}
.compose-field-label {
font-size: 13px;
color: var(--text-secondary);
width: 50px;
}
.compose-field input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-primary);
font-size: 13px;
}
.compose-body {
flex: 1;
padding: 12px 16px;
min-height: 200px;
}
.compose-body textarea {
width: 100%;
height: 100%;
min-height: 180px;
background: transparent;
border: none;
outline: none;
color: var(--text-primary);
font-size: 13px;
font-family: inherit;
resize: none;
}
.compose-footer {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border);
}
.loading {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--text-secondary);
font-size: 14px;
gap: 8px;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -0,0 +1,149 @@
import type { Email, UserProfile } from '../types';
// NOTE: In a real code app, these would be injected via the Power Platform
// client library (@microsoft/power-apps-code-solutions or similar).
// The client exposes typed services like Office365UsersService and
// OutlookService that wrap the connector calls.
//
// For this demo, we simulate the connector responses with mock data.
// To wire up real connectors:
// 1. Create connections in make.powerapps.com (e.g. "shared_office365", "shared_outlook")
// 2. Run: pac code add-data-source -a <apiName> -c <connectionId>
// 3. The generator creates typed Model and Service files automatically
//
// Real service usage would look like:
//
// import { OutlookService } from './services/OutlookService';
// const outlook = new OutlookService();
// const emails = await outlook.getMessages('inbox');
const MOCK_USER: UserProfile = {
displayName: 'Lago',
email: 'jose@lago.dev',
};
const MOCK_EMAILS: Email[] = [
{
id: '1',
from: 'jose@lago.dev',
fromName: 'Lago',
to: 'lago@example.com',
subject: 'Sprint planning — Thursday 10:00',
body: 'Hola,\n\nVamos a planificar el sprint del mes que viene el jueves a las 10:00. Necesito que reviseis los temas pendientes del backlog.\n\nUn saludo',
receivedAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(),
isRead: false,
folder: 'inbox',
},
{
id: '2',
from: 'maria@partner.com',
fromName: 'María García',
to: 'lago@example.com',
subject: 'RE: Presupuesto proyecto digital 2026',
body: 'Hola Lago,\n\nGracias por la información. He revisado el presupuesto y parece correcto. Adjunto la propuesta final.\n\n¿Podemos hablar mañana por teléfono?\n\nSaludos',
receivedAt: new Date(Date.now() - 1000 * 60 * 60 * 2).toISOString(),
isRead: false,
folder: 'inbox',
},
{
id: '3',
from: 'devops@company.com',
fromName: 'DevOps Team',
to: 'lago@example.com',
subject: '[Alert] Production deployment failed — pipeline #4821',
body: 'Pipeline #4821 failed at stage: build\n\nError: npm ERR! code ETARGET\nnpm ERR! notarget No valid target for react@19.0.1\n\nView logs: https://devops.company.com/pipelines/4821',
receivedAt: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(),
isRead: true,
folder: 'inbox',
},
{
id: '4',
from: 'lago@example.com',
fromName: 'Lago',
to: 'equipo@company.com',
subject: 'Resumen semanal — Power Platform',
body: 'Equipo,\n\nAquí va el resumen de lo hecho esta semana:\n\n• Demo de code apps publicada en el blog\n• Connector de Outlook integrado\n• Migración del backlog a Dataverse completada\n\nPara la semana que viene:\n• Terminar la integración con Teams\n• Revisar los permisos de producción\n\nSaludos',
receivedAt: new Date(Date.now() - 1000 * 60 * 60 * 24).toISOString(),
isRead: true,
folder: 'sent',
},
{
id: '5',
from: 'newsletter@techweekly.com',
fromName: 'Tech Weekly',
to: 'lago@example.com',
subject: 'This Week in AI & Power Platform — Issue #47',
body: 'Top stories this week:\n\n1. Microsoft announces GPT-5 integration for Copilot Studio\n2. Power Apps code apps reach 1,400+ connectors milestone\n3. Dataverse 2026 Wave 1 features now generally available\n\nRead more inside...',
receivedAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
isRead: true,
folder: 'inbox',
},
{
id: '6',
from: 'noreply@linkedin.com',
fromName: 'LinkedIn',
to: 'lago@example.com',
subject: '5 people viewed your profile this week',
body: 'Your profile got noticed!\n\n5 people in Austria viewed your profile in the past week, including recruiters and hiring managers.\n\nView who they are →',
receivedAt: new Date(Date.now() - 1000 * 60 * 60 * 72).toISOString(),
isRead: true,
folder: 'inbox',
},
{
id: '7',
from: 'lago@example.com',
fromName: 'Lago',
to: 'draft',
subject: 'Borrador: Propuesta reunión trimestral',
body: 'Borrador de la propuesta para la reunión trimestral con dirección...',
receivedAt: new Date(Date.now() - 1000 * 60 * 60 * 6).toISOString(),
isRead: true,
folder: 'drafts',
},
];
export class OutlookService {
private delay(ms: number) {
return new Promise((r) => setTimeout(r, ms));
}
async getProfile(): Promise<UserProfile> {
await this.delay(300);
return MOCK_USER;
}
async getMessages(folder: string = 'inbox'): Promise<Email[]> {
await this.delay(600);
return MOCK_EMAILS.filter((e) => e.folder === folder);
}
async getMessage(id: string): Promise<Email | undefined> {
await this.delay(200);
return MOCK_EMAILS.find((e) => e.id === id);
}
async sendMessage(email: Partial<Email>): Promise<Email> {
await this.delay(800);
const newEmail: Email = {
id: Date.now().toString(),
from: MOCK_USER.email,
fromName: MOCK_USER.displayName,
to: email.to || '',
subject: email.subject || '(Sin asunto)',
body: email.body || '',
receivedAt: new Date().toISOString(),
isRead: true,
folder: 'sent',
};
MOCK_EMAILS.push(newEmail);
return newEmail;
}
async markAsRead(id: string): Promise<void> {
await this.delay(100);
const email = MOCK_EMAILS.find((e) => e.id === id);
if (email) email.isRead = true;
}
}
export const outlookService = new OutlookService();

24
src/types/index.ts Normal file
View File

@@ -0,0 +1,24 @@
export interface Email {
id: string;
from: string;
fromName: string;
to: string;
subject: string;
body: string;
receivedAt: string;
isRead: boolean;
folder: 'inbox' | 'sent' | 'drafts' | 'trash';
}
export interface Folder {
id: string;
name: string;
icon: string;
unreadCount: number;
}
export interface UserProfile {
displayName: string;
email: string;
avatar?: string;
}