Forráskód Böngészése

feat: transform into full personal social media manager platform

- Add MongoDB, Redis services to docker-compose for persistence and job queues
- Add Mastodon, Bluesky platform services with real API integration
- Add feed-aggregator service (periodic multi-platform feed polling)
- Add scheduler service (BullMQ-based scheduled post management)
- Add BasePlatformService and MongoDBConnector shared utilities
- Upgrade Twitter service from stub to twitter-api-v2 real integration
- Redesign Vue 3 UI with Vue Router, Pinia, Dashboard, Compose, Scheduler, Settings views
- Add vue-i18n with English (default) and Turkish locales; new languages add in one file
- Add .env.example with all platform credential templates
- Update README with full setup guide and platform comparison table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
mehmet.kirkoca 2 hónapja
szülő
commit
5b0bd43980

+ 45 - 0
.env.example

@@ -0,0 +1,45 @@
+# ─── RabbitMQ ──────────────────────────────────────────────────────────────────
+RABBITMQ_URL=amqp://username:password@messageBroker:5672
+
+# ─── MongoDB ───────────────────────────────────────────────────────────────────
+MONGODB_URL=mongodb://mongodb:27017
+MONGODB_DB=socialmedia
+
+# ─── Redis ─────────────────────────────────────────────────────────────────────
+REDIS_URL=redis://redis:6379
+
+# ─── Twitter / X ───────────────────────────────────────────────────────────────
+# Developer portalından alınır: developer.twitter.com
+TWITTER_API_KEY=
+TWITTER_API_SECRET=
+TWITTER_ACCESS_TOKEN=
+TWITTER_ACCESS_SECRET=
+TWITTER_BEARER_TOKEN=
+
+# ─── LinkedIn ──────────────────────────────────────────────────────────────────
+# Developer portalından alınır: linkedin.com/developers
+LINKEDIN_CLIENT_ID=
+LINKEDIN_CLIENT_SECRET=
+
+# ─── Mastodon ──────────────────────────────────────────────────────────────────
+# Hesabın olduğu instance URL (örn: https://mastodon.social)
+MASTODON_INSTANCE_URL=https://mastodon.social
+# Instance'ın Settings > Development > New application bölümünden alınır
+MASTODON_ACCESS_TOKEN=
+
+# ─── Bluesky ───────────────────────────────────────────────────────────────────
+# Kullanıcı adın (örn: user.bsky.social)
+BLUESKY_IDENTIFIER=
+# Settings > App Passwords bölümünden oluşturulan şifre (gerçek şifreni kullanma!)
+BLUESKY_APP_PASSWORD=
+
+# ─── Instagram ─────────────────────────────────────────────────────────────────
+# Facebook Developer App üzerinden alınır (Business/Creator hesabı gerekli)
+INSTAGRAM_ACCESS_TOKEN=
+
+# ─── Reddit ────────────────────────────────────────────────────────────────────
+# reddit.com/prefs/apps üzerinden oluşturulan uygulama
+REDDIT_CLIENT_ID=
+REDDIT_CLIENT_SECRET=
+REDDIT_USERNAME=
+REDDIT_PASSWORD=

+ 3 - 0
.gitignore

@@ -68,3 +68,6 @@ Thumbs.db
 
 # Test coverage directory used by tools like istanbul
 coverage/
+
+# Docs folder
+docs/

+ 157 - 40
README.md

@@ -1,48 +1,165 @@
-# Simplify Social Media Management with our Web Application
+# SocialManager — Personal Social Media Manager
 
-## Introduction
+A self-hosted, local-first social media management platform. Aggregate feeds from all your platforms, compose and cross-post content, schedule posts, and get AI-powered suggestions — all from one dashboard.
+
+---
 
-Welcome to our innovative project, an all-inclusive web application designed to revolutionize how we manage social media. Our goal is to provide a unified platform that simplifies the complexities of handling multiple social media platforms, streamlining tasks, and optimizing engagement.
+## Features
 
-## Project Overview
+- **Unified Feed** — Pull feeds from Twitter/X, Mastodon, Bluesky, LinkedIn, Instagram, Reddit and YouTube into a single dashboard
+- **Filter & Tag** — Filter your feed by platform or custom tags
+- **Cross-post** — Write once, publish to multiple platforms simultaneously
+- **Scheduler** — Schedule posts for a specific date/time with BullMQ job queue
+- **AI Assistance** — Grammar correction and platform-specific content adaptation (T5 model, runs locally)
+- **Multi-language UI** — English and Turkish built-in; adding a new language is a single file
+- **Microservices** — Each platform is an independent service, easy to add or remove
+- **Fully local** — No SaaS, no subscriptions. Runs entirely on your machine via Docker
+
+---
 
-We are excited to introduce an innovative endeavor aimed at transforming the landscape of social media management. Our primary objective is to seamlessly integrate a variety of social media APIs into a unified solution, consolidating the management of multiple social media channels within a single, robust application.
+## Tech Stack
 
-![Alt text](socialMediaManager.png?raw=true "Social Media Manager")
+| Layer | Technology |
+|-------|-----------|
+| Frontend | Vue 3, TypeScript, Vite, Tailwind CSS, Pinia, Vue Router, vue-i18n |
+| API Gateway | Node.js / Fastify |
+| Message Broker | RabbitMQ |
+| Database | MongoDB |
+| Job Queue | Redis + BullMQ |
+| AI Service | Python / HappyTransformer (T5) |
+| Platform SDKs | twitter-api-v2, masto, @atproto/api |
+| Reverse Proxy | Nginx |
+| Containerization | Docker Compose |
 
-## The Vision
+---
 
-Imagine a powerful web app that empowers you to efficiently oversee your presence across a plethora of social media platforms. Our app's core functionality revolves around automating tasks such as cross-platform content reposting, complete with AI-driven formatting adjustments tailored for each specific platform. By leveraging APIs from different social media platforms, our app becomes the centralized hub for managing all your accounts.
+## Services
 
-Additionally, our app offers a convenient scheduling feature that enables you to plan and publish posts across diverse social media platforms. This is particularly advantageous when catering to a global audience spread across different time zones. With our scheduling feature, your content can go live at the optimal moment, ensuring maximum reach and impact.
-
-## Benefits for Developers
-
-This project addresses real-world scenarios that many developers encounter. Whether you're a business looking to efficiently share updates across multiple social media accounts or a social media influencer aiming to automate engagement with specific hashtags, our app eliminates the complexity of these processes, allowing you to focus on creating meaningful content.
-
-## The Technical Challenge
-
-A significant technical challenge we're addressing involves formatting individual posts for various social media platforms, including Twitter, Facebook, Instagram, and LinkedIn. Furthermore, the intricacies of these platforms' APIs and integrations can pose difficulties for individual developers. To overcome these challenges, our strategy involves leveraging a combination of official API wrappers and AI techniques for post formatting across different platforms.
-
-## Key Technical Features
-
-- **Time Management and Automated Formatting**: Our app excels in efficient time management and automates the process of formatting posts according to each platform's requirements.
-
-## Get Involved!
-
-Your insights and contributions are invaluable to us. If you're excited about the potential of this app, show your support by giving it a ⭐️ on GitHub. If you're eager to contribute, feel free to fork the project and become an active participant. Encountered a bug or have an innovative feature in mind? Open an issue, and let's have a constructive discussion. Together, we're building a scalable code architecture that caters to a diverse range of use cases.
-
-## Frequently Asked Questions
-
-- **Tech Stack**: We've chosen a robust tech stack for this project. The backend will be built using Node.js, employing a microservices architecture. On the frontend, we're utilizing Vue.js to create a seamless user experience. Additionally, our deployment includes Nginx, RabbitMQ for messaging, and Docker for containerization.
-
-## How to Get Started
-
-Here's a quick guide on how to set up and run the project using Docker commands:
-
-1. Clone the repository to your local machine.
-2. Navigate to the project directory.
-3. Run `docker-compose up` to start the microservices, frontend, and backend.
-4. Access the application by opening your web browser and navigating to the [specified URL](http://localhost:8081).
-
-Your enthusiasm and involvement in this project are immensely appreciated. Let's collaborate to revolutionize social media management, one commit at a time!
+| Service | Port | Description |
+|---------|------|-------------|
+| `nginx` | 8081 | Reverse proxy — main entry point |
+| `ui` | — | Vue 3 frontend (Vite dev server) |
+| `gateway` | 8084 | REST API gateway |
+| `socket` | 8085 | WebSocket server (real-time feed updates) |
+| `formatter` | — | Platform-specific content formatter |
+| `ai-grammar-correction` | — | AI grammar correction (T5) |
+| `feed-aggregator` | 3010 | Pulls feeds from all platforms periodically |
+| `scheduler` | 3011 | Scheduled post management (BullMQ) |
+| `twitter` | 3001 | Twitter/X integration |
+| `linkedin` | 3002 | LinkedIn integration |
+| `mastodon` | 3003 | Mastodon integration |
+| `bluesky` | 3004 | Bluesky (AT Protocol) integration |
+| `mongodb` | 27018 | Database |
+| `redis` | 6379 | Cache & job queue |
+| `messageBroker` | 5672 / 15672 | RabbitMQ (+ management UI) |
+
+---
+
+## Getting Started
+
+### 1. Clone the repository
+
+```bash
+git clone https://github.com/mehmetkirkoca/social-media-manager.git
+cd social-media-manager
+```
+
+### 2. Configure environment
+
+```bash
+cp .env.example .env
+```
+
+Edit `.env` and fill in your API credentials for the platforms you want to use. You can start with just Mastodon or Bluesky — both have free, open APIs.
+
+```env
+# Mastodon (easiest — get token from instance Settings > Development)
+MASTODON_INSTANCE_URL=https://mastodon.social
+MASTODON_ACCESS_TOKEN=your_token_here
+
+# Bluesky (use an App Password from Settings > App Passwords)
+BLUESKY_IDENTIFIER=yourhandle.bsky.social
+BLUESKY_APP_PASSWORD=your_app_password_here
+```
+
+### 3. Start the application
+
+```bash
+docker compose up -d
+```
+
+Open **http://localhost:8081** in your browser.
+
+---
+
+## Platform Connection Guide
+
+| Platform | API Cost | Feed | Post | Notes |
+|----------|----------|------|------|-------|
+| Mastodon | Free | ✅ | ✅ | Easiest — open REST API |
+| Bluesky | Free | ✅ | ✅ | App Password auth, no OAuth needed |
+| Reddit | Free | ✅ | ✅ | Register an app at reddit.com/prefs/apps |
+| Twitter/X | Paid ($100/mo Basic) | ⚠️ | ✅ | Free tier very limited |
+| LinkedIn | Free | ⚠️ | ✅ | Personal feed read not available via API |
+| Instagram | Free | ⚠️ | ⚠️ | Business/Creator account required |
+| YouTube | Free | ✅ | ❌ | Subscription feed read-only |
+
+---
+
+## Adding a New Language
+
+1. Create `ui/src/locales/xx.ts` (copy `en.ts` and translate)
+2. In `ui/src/locales/index.ts`:
+   ```ts
+   import xx from './xx'
+   // Add to messages: { en, tr, xx }
+   // Add to SUPPORTED_LOCALES: { code: 'xx', label: '...', flag: '🇽🇽' }
+   ```
+3. Done — language will appear in the NavBar dropdown automatically
+
+---
+
+## Adding a New Platform
+
+1. Create `services/{platform}/` with `index.js`, `package.json`, `Dockerfile`
+2. Extend `BasePlatformService` and implement `fetchFeed()`, `publishPost()`, `getStatus()`
+3. Add the service to `docker-compose.yml`
+4. Add the service URL to `feed-aggregator` and `scheduler` environment variables
+5. Add platform metadata to `ui/src/stores/platforms.ts`
+
+---
+
+## Project Structure
+
+```
+.
+├── services/
+│   ├── utils/               # Shared: RabbitMQ, MongoDB, BasePlatformService
+│   ├── gateway/             # API gateway
+│   ├── socket/              # WebSocket server
+│   ├── formatter/           # Content formatter
+│   ├── ai_grammar_correction/
+│   ├── feed-aggregator/
+│   ├── scheduler/
+│   ├── twitter/
+│   ├── linkedin/
+│   ├── mastodon/
+│   └── bluesky/
+├── ui/
+│   └── src/
+│       ├── views/           # Dashboard, Compose, Scheduler, Settings
+│       ├── components/
+│       ├── stores/          # Pinia: feed, compose, platforms
+│       ├── locales/         # i18n: en, tr
+│       └── router/
+├── docs/                    # Architecture, roadmap, platform guides (gitignored)
+├── docker-compose.yml
+├── nginx.conf
+└── .env.example
+```
+
+---
+
+## License
+
+[LICENSE.txt](LICENSE.txt)

+ 115 - 9
docker-compose.yml

@@ -1,5 +1,3 @@
-version: '3.8'
-
 services:
 
   nginx:
@@ -28,6 +26,30 @@ services:
     networks:
       - socialMediaManagerNetwork
 
+  mongodb:
+    container_name: mongodb
+    image: mongo:7
+    restart: unless-stopped
+    ports:
+      - 27018:27017
+    environment:
+      MONGO_INITDB_DATABASE: socialmedia
+    volumes:
+      - mongodb-data:/data/db
+    networks:
+      - socialMediaManagerNetwork
+
+  redis:
+    container_name: redis
+    image: redis:7-alpine
+    restart: unless-stopped
+    ports:
+      - 6379:6379
+    volumes:
+      - redis-data:/data
+    networks:
+      - socialMediaManagerNetwork
+
   ai-grammer-correction:
     build: ./services/ai_grammar_correction
     volumes:
@@ -43,16 +65,18 @@ services:
     volumes:
       - ./services/utils:/services/gateway/utils
       - ./services/gateway:/services/gateway
+      - gateway_modules:/services/gateway/node_modules
     networks:
       - socialMediaManagerNetwork
     depends_on:
-      - messageBroker  
-  
+      - messageBroker
+
   socket:
     build: ./services/socket
     volumes:
       - ./services/utils:/services/socket/utils
       - ./services/socket:/services/socket
+      - socket_modules:/services/socket/node_modules
     networks:
       - socialMediaManagerNetwork
     depends_on:
@@ -63,6 +87,7 @@ services:
     volumes:
       - ./services/utils:/services/formatter/utils
       - ./services/formatter:/services/formatter
+      - formatter_modules:/services/formatter/node_modules
     restart: unless-stopped
     networks:
       - socialMediaManagerNetwork
@@ -70,31 +95,98 @@ services:
       - messageBroker
 
   twitter:
-    build: ./services/twitter
+    build:
+      context: ./services
+      dockerfile: twitter/dockerfile
     volumes:
-      - ./services/utils:/services/twitter/utils
-      - ./services/twitter:/services/twitter
+      - twitter_modules:/services/twitter/node_modules
     restart: unless-stopped
+    env_file: .env
     networks:
       - socialMediaManagerNetwork
     depends_on:
       - messageBroker
-      
+      - mongodb
+
   linkedin:
     build: ./services/linkedin
     volumes:
       - ./services/utils:/services/linkedin/utils
       - ./services/linkedin:/services/linkedin
+      - linkedin_modules:/services/linkedin/node_modules
     restart: unless-stopped
+    env_file: .env
     networks:
       - socialMediaManagerNetwork
     depends_on:
       - messageBroker
+      - mongodb
+
+  mastodon:
+    build:
+      context: ./services
+      dockerfile: mastodon/Dockerfile
+    volumes:
+      - mastodon_modules:/services/mastodon/node_modules
+    restart: unless-stopped
+    env_file: .env
+    networks:
+      - socialMediaManagerNetwork
+    depends_on:
+      - messageBroker
+      - mongodb
+
+  bluesky:
+    build:
+      context: ./services
+      dockerfile: bluesky/Dockerfile
+    volumes:
+      - bluesky_modules:/services/bluesky/node_modules
+    restart: unless-stopped
+    env_file: .env
+    networks:
+      - socialMediaManagerNetwork
+    depends_on:
+      - messageBroker
+      - mongodb
+
+  feed-aggregator:
+    build:
+      context: ./services
+      dockerfile: feed-aggregator/Dockerfile
+    volumes:
+      - feed_aggregator_modules:/services/feed-aggregator/node_modules
+    restart: unless-stopped
+    env_file: .env
+    networks:
+      - socialMediaManagerNetwork
+    depends_on:
+      - messageBroker
+      - mongodb
+      - twitter
+      - linkedin
+      - mastodon
+      - bluesky
+
+  scheduler:
+    build:
+      context: ./services
+      dockerfile: scheduler/Dockerfile
+    volumes:
+      - scheduler_modules:/services/scheduler/node_modules
+    restart: unless-stopped
+    env_file: .env
+    networks:
+      - socialMediaManagerNetwork
+    depends_on:
+      - mongodb
+      - redis
 
   ui:
     build: ./ui
     volumes:
       - ./ui:/app/ui
+      - ui_modules:/app/ui/node_modules
     networks:
       - socialMediaManagerNetwork
     depends_on:
@@ -102,4 +194,18 @@ services:
 
 networks:
   socialMediaManagerNetwork:
-    driver: bridge
+    driver: bridge
+
+volumes:
+  mongodb-data:
+  redis-data:
+  gateway_modules:
+  socket_modules:
+  formatter_modules:
+  twitter_modules:
+  linkedin_modules:
+  mastodon_modules:
+  bluesky_modules:
+  feed_aggregator_modules:
+  scheduler_modules:
+  ui_modules:

+ 14 - 0
nginx.conf

@@ -35,5 +35,19 @@ http {
       proxy_set_header Host $host;
       proxy_set_header X-Real-IP $remote_addr;
     }
+
+    location /feeds/ {
+      rewrite ^/feeds/(.*) /$1 break;
+      proxy_pass http://feed-aggregator:3010;
+      proxy_set_header Host $host;
+      proxy_set_header X-Real-IP $remote_addr;
+    }
+
+    location /scheduler/ {
+      rewrite ^/scheduler/(.*) /$1 break;
+      proxy_pass http://scheduler:3011;
+      proxy_set_header Host $host;
+      proxy_set_header X-Real-IP $remote_addr;
+    }
   }
 }

+ 7 - 0
services/bluesky/Dockerfile

@@ -0,0 +1,7 @@
+FROM node:20-alpine
+WORKDIR /services/bluesky
+COPY bluesky/package*.json ./
+RUN npm install
+COPY utils ./utils
+COPY bluesky/ .
+CMD ["node", "index.js"]

+ 137 - 0
services/bluesky/index.js

@@ -0,0 +1,137 @@
+require('dotenv').config();
+const { BskyAgent, RichText } = require('@atproto/api');
+const BasePlatformService = require('./utils/BasePlatformService');
+const { getDb } = require('./utils/MongoDBConnector');
+
+const BLUESKY_IDENTIFIER = process.env.BLUESKY_IDENTIFIER || '';
+const BLUESKY_APP_PASSWORD = process.env.BLUESKY_APP_PASSWORD || '';
+const BLUESKY_SERVICE = process.env.BLUESKY_SERVICE || 'https://bsky.social';
+
+class BlueskyService extends BasePlatformService {
+  constructor() {
+    super('bluesky');
+    this.agent = new BskyAgent({ service: BLUESKY_SERVICE });
+    this.loggedIn = false;
+  }
+
+  async _ensureLoggedIn() {
+    if (this.loggedIn) return;
+    if (!BLUESKY_IDENTIFIER || !BLUESKY_APP_PASSWORD) {
+      throw new Error('Bluesky credentials not configured');
+    }
+    await this.agent.login({
+      identifier: BLUESKY_IDENTIFIER,
+      password: BLUESKY_APP_PASSWORD,
+    });
+    this.loggedIn = true;
+  }
+
+  async getStatus() {
+    if (!BLUESKY_IDENTIFIER || !BLUESKY_APP_PASSWORD) {
+      return { connected: false, platform: 'bluesky', error: 'Credentials not configured' };
+    }
+    try {
+      await this._ensureLoggedIn();
+      const profile = await this.agent.getProfile({ actor: BLUESKY_IDENTIFIER });
+      return {
+        connected: true,
+        platform: 'bluesky',
+        username: profile.data.handle,
+        displayName: profile.data.displayName,
+        avatar: profile.data.avatar,
+      };
+    } catch (err) {
+      this.loggedIn = false;
+      return { connected: false, platform: 'bluesky', error: err.message };
+    }
+  }
+
+  async fetchFeed({ limit = 50 } = {}) {
+    await this._ensureLoggedIn();
+
+    const response = await this.agent.getTimeline({ limit: Number(limit) });
+    const items = response.data.feed
+      .filter((entry) => entry.post)
+      .map((entry) => {
+        const post = entry.post;
+        const author = post.author;
+        const record = post.record;
+
+        return this.normalizeFeedItem({
+          originalId: post.uri,
+          author: {
+            name: author.displayName || author.handle,
+            username: author.handle,
+            avatar: author.avatar,
+            profileUrl: `https://bsky.app/profile/${author.handle}`,
+          },
+          content: record.text || '',
+          media: _extractMedia(post.embed),
+          platformTags: _extractTags(record.facets),
+          metrics: {
+            likes: post.likeCount || 0,
+            shares: post.repostCount || 0,
+            comments: post.replyCount || 0,
+          },
+          url: `https://bsky.app/profile/${author.handle}/post/${post.uri.split('/').pop()}`,
+          createdAt: record.createdAt,
+        });
+      });
+
+    // MongoDB'ye kaydet (upsert)
+    try {
+      const db = await getDb();
+      const col = db.collection('feeds');
+      for (const item of items) {
+        await col.updateOne(
+          { platform: 'bluesky', originalId: item.originalId },
+          { $set: item },
+          { upsert: true }
+        );
+      }
+    } catch (err) {
+      console.error('[Bluesky] MongoDB write error:', err.message);
+    }
+
+    return items;
+  }
+
+  async publishPost({ content, media = [] } = {}) {
+    await this._ensureLoggedIn();
+
+    const rt = new RichText({ text: content });
+    await rt.detectFacets(this.agent);
+
+    const postData = { text: rt.text, facets: rt.facets };
+
+    if (media.length > 0) {
+      postData.embed = { $type: 'app.bsky.embed.images', images: media };
+    }
+
+    const result = await this.agent.post(postData);
+    return { uri: result.uri, cid: result.cid };
+  }
+}
+
+function _extractMedia(embed) {
+  if (!embed) return [];
+  if (embed.$type === 'app.bsky.embed.images#view') {
+    return (embed.images || []).map((img) => ({
+      url: img.fullsize || img.thumb,
+      type: 'image',
+      thumbnail: img.thumb,
+      alt: img.alt,
+    }));
+  }
+  return [];
+}
+
+function _extractTags(facets = []) {
+  if (!facets) return [];
+  return facets
+    .filter((f) => f.features?.some((feat) => feat.$type === 'app.bsky.richtext.facet#tag'))
+    .flatMap((f) => f.features.filter((feat) => feat.$type === 'app.bsky.richtext.facet#tag').map((feat) => feat.tag));
+}
+
+const service = new BlueskyService();
+service.start(process.env.PORT || 3004);

+ 20 - 0
services/bluesky/package.json

@@ -0,0 +1,20 @@
+{
+  "name": "bluesky-service",
+  "version": "1.0.0",
+  "description": "Bluesky (AT Protocol) platform service",
+  "main": "index.js",
+  "scripts": {
+    "start": "node index.js",
+    "dev": "nodemon index.js"
+  },
+  "dependencies": {
+    "@atproto/api": "^0.12.0",
+    "fastify": "^4.24.3",
+    "mongodb": "^6.3.0",
+    "amqplib": "^0.10.3",
+    "dotenv": "^16.3.1"
+  },
+  "devDependencies": {
+    "nodemon": "^3.0.2"
+  }
+}

+ 7 - 0
services/feed-aggregator/Dockerfile

@@ -0,0 +1,7 @@
+FROM node:20-alpine
+WORKDIR /services/feed-aggregator
+COPY feed-aggregator/package*.json ./
+RUN npm install
+COPY utils ./utils
+COPY feed-aggregator/ .
+CMD ["node", "index.js"]

+ 126 - 0
services/feed-aggregator/index.js

@@ -0,0 +1,126 @@
+require('dotenv').config();
+const Fastify = require('fastify');
+const axios = require('axios');
+const cron = require('node-cron');
+const { getDb } = require('./utils/MongoDBConnector');
+const RabbitMQProducer = require('./utils/RabbitMQProducer');
+
+const FEED_REFRESH_INTERVAL = process.env.FEED_REFRESH_INTERVAL || '*/5 * * * *'; // Her 5 dakika
+
+// Platform servis URL'leri (docker network içinde)
+const PLATFORM_SERVICES = {
+  twitter:  process.env.TWITTER_SERVICE_URL  || 'http://twitter:3001',
+  linkedin: process.env.LINKEDIN_SERVICE_URL || 'http://linkedin:3002',
+  mastodon: process.env.MASTODON_SERVICE_URL || 'http://mastodon:3003',
+  bluesky:  process.env.BLUESKY_SERVICE_URL  || 'http://bluesky:3004',
+};
+
+const app = Fastify({ logger: false });
+let producer;
+
+// ─── Feed Çekme ──────────────────────────────────────────────────────────────
+
+async function fetchPlatformFeed(platform, serviceUrl) {
+  try {
+    const response = await axios.get(`${serviceUrl}/feed`, { timeout: 15000 });
+    const items = response.data.items || [];
+    console.log(`[FeedAggregator] ${platform}: ${items.length} öğe çekildi`);
+
+    // WebSocket üzerinden UI'ya bildir
+    if (producer && items.length > 0) {
+      await producer.sendMessage('feed.items', JSON.stringify({ platform, items, fetchedAt: new Date() }));
+    }
+
+    return items;
+  } catch (err) {
+    console.error(`[FeedAggregator] ${platform} feed hatası:`, err.message);
+    return [];
+  }
+}
+
+async function fetchAllFeeds() {
+  console.log('[FeedAggregator] Tüm platformlardan feed çekiliyor...');
+
+  const results = await Promise.allSettled(
+    Object.entries(PLATFORM_SERVICES).map(([platform, url]) =>
+      fetchPlatformFeed(platform, url)
+    )
+  );
+
+  const summary = {};
+  Object.keys(PLATFORM_SERVICES).forEach((platform, i) => {
+    summary[platform] = results[i].status === 'fulfilled' ? results[i].value.length : 0;
+  });
+
+  console.log('[FeedAggregator] Tamamlandı:', summary);
+  return summary;
+}
+
+// ─── HTTP Endpoints ──────────────────────────────────────────────────────────
+
+app.get('/health', async () => ({ status: 'ok', service: 'feed-aggregator' }));
+
+app.post('/fetch', async (request) => {
+  const { platform } = request.body || {};
+  if (platform && PLATFORM_SERVICES[platform]) {
+    const items = await fetchPlatformFeed(platform, PLATFORM_SERVICES[platform]);
+    return { success: true, platform, count: items.length };
+  }
+  const summary = await fetchAllFeeds();
+  return { success: true, summary };
+});
+
+app.get('/feeds', async (request) => {
+  const { platform, tag, limit = 50, skip = 0 } = request.query;
+  const db = await getDb();
+  const col = db.collection('feeds');
+
+  const filter = {};
+  if (platform) filter.platform = platform;
+  if (tag) filter.tags = tag;
+
+  const items = await col
+    .find(filter)
+    .sort({ createdAt: -1 })
+    .skip(Number(skip))
+    .limit(Number(limit))
+    .toArray();
+
+  return { success: true, count: items.length, items };
+});
+
+app.get('/platform-status', async () => {
+  const statuses = await Promise.allSettled(
+    Object.entries(PLATFORM_SERVICES).map(async ([platform, url]) => {
+      const response = await axios.get(`${url}/status`, { timeout: 5000 });
+      return { platform, ...response.data };
+    })
+  );
+
+  return statuses.map((r, i) => {
+    const platform = Object.keys(PLATFORM_SERVICES)[i];
+    return r.status === 'fulfilled'
+      ? r.value
+      : { platform, connected: false, error: r.reason.message };
+  });
+});
+
+// ─── Başlatma ────────────────────────────────────────────────────────────────
+
+async function start() {
+  producer = new RabbitMQProducer();
+  await producer.connect();
+
+  // Periyodik feed yenileme
+  cron.schedule(FEED_REFRESH_INTERVAL, () => {
+    fetchAllFeeds().catch(console.error);
+  });
+
+  await app.listen({ port: process.env.PORT || 3010, host: '0.0.0.0' });
+  console.log(`[FeedAggregator] Started. Cron: ${FEED_REFRESH_INTERVAL}`);
+
+  // İlk çalıştırma
+  setTimeout(() => fetchAllFeeds(), 5000);
+}
+
+start().catch(console.error);

+ 21 - 0
services/feed-aggregator/package.json

@@ -0,0 +1,21 @@
+{
+  "name": "feed-aggregator",
+  "version": "1.0.0",
+  "description": "Feed aggregation service — pulls feeds from all platform services",
+  "main": "index.js",
+  "scripts": {
+    "start": "node index.js",
+    "dev": "nodemon index.js"
+  },
+  "dependencies": {
+    "fastify": "^4.24.3",
+    "axios": "^1.6.2",
+    "node-cron": "^3.0.3",
+    "amqplib": "^0.10.3",
+    "mongodb": "^6.3.0",
+    "dotenv": "^16.3.1"
+  },
+  "devDependencies": {
+    "nodemon": "^3.0.2"
+  }
+}

+ 7 - 0
services/mastodon/Dockerfile

@@ -0,0 +1,7 @@
+FROM node:20-alpine
+WORKDIR /services/mastodon
+COPY mastodon/package*.json ./
+RUN npm install
+COPY utils ./utils
+COPY mastodon/ .
+CMD ["node", "index.js"]

+ 115 - 0
services/mastodon/index.js

@@ -0,0 +1,115 @@
+require('dotenv').config();
+const { createRestAPIClient } = require('masto');
+const BasePlatformService = require('./utils/BasePlatformService');
+const { getDb } = require('./utils/MongoDBConnector');
+
+const MASTODON_INSTANCE_URL = process.env.MASTODON_INSTANCE_URL || 'https://mastodon.social';
+const MASTODON_ACCESS_TOKEN = process.env.MASTODON_ACCESS_TOKEN || '';
+
+class MastodonService extends BasePlatformService {
+  constructor() {
+    super('mastodon');
+    this.client = null;
+  }
+
+  _initClient() {
+    if (!MASTODON_ACCESS_TOKEN) return null;
+    return createRestAPIClient({
+      url: MASTODON_INSTANCE_URL,
+      accessToken: MASTODON_ACCESS_TOKEN,
+    });
+  }
+
+  async getStatus() {
+    if (!MASTODON_ACCESS_TOKEN) {
+      return { connected: false, platform: 'mastodon', error: 'Access token not configured' };
+    }
+    try {
+      const client = this._initClient();
+      const account = await client.v1.accounts.verifyCredentials();
+      return {
+        connected: true,
+        platform: 'mastodon',
+        username: account.username,
+        displayName: account.displayName,
+        avatar: account.avatar,
+        instance: MASTODON_INSTANCE_URL,
+      };
+    } catch (err) {
+      return { connected: false, platform: 'mastodon', error: err.message };
+    }
+  }
+
+  async fetchFeed({ limit = 40 } = {}) {
+    const client = this._initClient();
+    if (!client) throw new Error('Mastodon access token not configured');
+
+    const statuses = await client.v1.timelines.home.list({ limit: Number(limit) });
+
+    const items = statuses.map((status) =>
+      this.normalizeFeedItem({
+        originalId: status.id,
+        author: {
+          name: status.account.displayName || status.account.username,
+          username: `${status.account.username}@${new URL(MASTODON_INSTANCE_URL).hostname}`,
+          avatar: status.account.avatar,
+          profileUrl: status.account.url,
+        },
+        content: status.text || _stripHtml(status.content),
+        contentHtml: status.content,
+        media: (status.mediaAttachments || []).map((m) => ({
+          url: m.url,
+          type: m.type,
+          thumbnail: m.previewUrl,
+          alt: m.description,
+        })),
+        platformTags: (status.tags || []).map((t) => t.name),
+        metrics: {
+          likes: status.favouritesCount,
+          shares: status.reblogsCount,
+          comments: status.repliesCount,
+        },
+        url: status.url,
+        createdAt: status.createdAt,
+      })
+    );
+
+    // MongoDB'ye kaydet (upsert)
+    try {
+      const db = await getDb();
+      const col = db.collection('feeds');
+      for (const item of items) {
+        await col.updateOne(
+          { platform: 'mastodon', originalId: item.originalId },
+          { $set: item },
+          { upsert: true }
+        );
+      }
+    } catch (err) {
+      console.error('[Mastodon] MongoDB write error:', err.message);
+    }
+
+    return items;
+  }
+
+  async publishPost({ content, media = [], sensitive = false, spoilerText = '' } = {}) {
+    const client = this._initClient();
+    if (!client) throw new Error('Mastodon access token not configured');
+
+    const status = await client.v1.statuses.create({
+      status: content,
+      sensitive,
+      spoilerText,
+      mediaIds: media,
+    });
+
+    return { id: status.id, url: status.url };
+  }
+}
+
+function _stripHtml(html = '') {
+  return html.replace(/<[^>]+>/g, '').trim();
+}
+
+const service = new MastodonService();
+service.start(process.env.PORT || 3003);

+ 20 - 0
services/mastodon/package.json

@@ -0,0 +1,20 @@
+{
+  "name": "mastodon-service",
+  "version": "1.0.0",
+  "description": "Mastodon platform service",
+  "main": "index.js",
+  "scripts": {
+    "start": "node index.js",
+    "dev": "nodemon index.js"
+  },
+  "dependencies": {
+    "fastify": "^4.24.3",
+    "masto": "^6.7.0",
+    "mongodb": "^6.3.0",
+    "amqplib": "^0.10.3",
+    "dotenv": "^16.3.1"
+  },
+  "devDependencies": {
+    "nodemon": "^3.0.2"
+  }
+}

+ 7 - 0
services/scheduler/Dockerfile

@@ -0,0 +1,7 @@
+FROM node:20-alpine
+WORKDIR /services/scheduler
+COPY scheduler/package*.json ./
+RUN npm install
+COPY utils ./utils
+COPY scheduler/ .
+CMD ["node", "index.js"]

+ 156 - 0
services/scheduler/index.js

@@ -0,0 +1,156 @@
+require('dotenv').config();
+const Fastify = require('fastify');
+const { Queue, Worker, QueueEvents } = require('bullmq');
+const IORedis = require('ioredis');
+const axios = require('axios');
+const { getDb, connect } = require('./utils/MongoDBConnector');
+
+const REDIS_URL = process.env.REDIS_URL || 'redis://redis:6379';
+
+const PLATFORM_SERVICES = {
+  twitter:  process.env.TWITTER_SERVICE_URL  || 'http://twitter:3001',
+  linkedin: process.env.LINKEDIN_SERVICE_URL || 'http://linkedin:3002',
+  mastodon: process.env.MASTODON_SERVICE_URL || 'http://mastodon:3003',
+  bluesky:  process.env.BLUESKY_SERVICE_URL  || 'http://bluesky:3004',
+};
+
+const app = Fastify({ logger: false });
+let postQueue;
+let redis;
+
+// ─── Job Worker ──────────────────────────────────────────────────────────────
+
+async function processPostJob(job) {
+  const { postId, content, platforms, media = [] } = job.data;
+  console.log(`[Scheduler] Job ${job.id} çalışıyor: ${platforms.join(', ')}`);
+
+  const db = await getDb();
+  const results = {};
+
+  for (const platform of platforms) {
+    const serviceUrl = PLATFORM_SERVICES[platform];
+    if (!serviceUrl) {
+      results[platform] = { success: false, error: 'Bilinmeyen platform' };
+      continue;
+    }
+    try {
+      const response = await axios.post(`${serviceUrl}/post`, { content, media }, { timeout: 30000 });
+      results[platform] = { success: true, ...response.data.result };
+    } catch (err) {
+      results[platform] = { success: false, error: err.message };
+    }
+  }
+
+  // MongoDB güncelle
+  await db.collection('posts').updateOne(
+    { _id: postId },
+    {
+      $set: {
+        status: Object.values(results).every((r) => r.success) ? 'published' : 'failed',
+        publishedAt: new Date(),
+        platformResults: results,
+      },
+    }
+  );
+
+  await db.collection('scheduled_jobs').updateOne(
+    { bullJobId: String(job.id) },
+    {
+      $set: {
+        status: 'completed',
+        completedAt: new Date(),
+      },
+    }
+  );
+
+  return results;
+}
+
+// ─── HTTP Endpoints ──────────────────────────────────────────────────────────
+
+app.get('/health', async () => ({ status: 'ok', service: 'scheduler' }));
+
+// Yeni zamanlanmış gönderi oluştur
+app.post('/schedule', async (request, reply) => {
+  const { postId, content, platforms, scheduledAt, media = [] } = request.body;
+
+  if (!content || !platforms?.length || !scheduledAt) {
+    return reply.code(400).send({ error: 'content, platforms ve scheduledAt zorunlu' });
+  }
+
+  const delay = new Date(scheduledAt).getTime() - Date.now();
+  if (delay < 0) {
+    return reply.code(400).send({ error: 'scheduledAt geçmiş bir tarih olamaz' });
+  }
+
+  const job = await postQueue.add(
+    'scheduled-post',
+    { postId, content, platforms, media },
+    { delay, attempts: 3, backoff: { type: 'exponential', delay: 60000 } }
+  );
+
+  // MongoDB kayıt
+  const db = await getDb();
+  await db.collection('scheduled_jobs').insertOne({
+    postId,
+    type: 'one-time',
+    scheduledAt: new Date(scheduledAt),
+    platforms,
+    status: 'pending',
+    attempts: 0,
+    maxAttempts: 3,
+    bullJobId: String(job.id),
+    createdAt: new Date(),
+  });
+
+  return { success: true, jobId: job.id, scheduledAt };
+});
+
+// Zamanlanmış görevleri listele
+app.get('/jobs', async (request) => {
+  const { status = 'pending' } = request.query;
+  const db = await getDb();
+  const jobs = await db
+    .collection('scheduled_jobs')
+    .find({ status })
+    .sort({ scheduledAt: 1 })
+    .toArray();
+  return { success: true, count: jobs.length, jobs };
+});
+
+// Görevi iptal et
+app.delete('/jobs/:jobId', async (request, reply) => {
+  const { jobId } = request.params;
+  const job = await postQueue.getJob(jobId);
+  if (!job) return reply.code(404).send({ error: 'Job bulunamadı' });
+
+  await job.remove();
+
+  const db = await getDb();
+  await db.collection('scheduled_jobs').updateOne(
+    { bullJobId: jobId },
+    { $set: { status: 'cancelled' } }
+  );
+
+  return { success: true, jobId };
+});
+
+// ─── Başlatma ────────────────────────────────────────────────────────────────
+
+async function start() {
+  await connect();
+
+  redis = new IORedis(REDIS_URL, { maxRetriesPerRequest: null });
+
+  postQueue = new Queue('post-queue', { connection: redis });
+
+  const worker = new Worker('post-queue', processPostJob, { connection: redis });
+  worker.on('failed', (job, err) => {
+    console.error(`[Scheduler] Job ${job?.id} başarısız:`, err.message);
+  });
+
+  await app.listen({ port: process.env.PORT || 3011, host: '0.0.0.0' });
+  console.log('[Scheduler] Started on port 3011');
+}
+
+start().catch(console.error);

+ 22 - 0
services/scheduler/package.json

@@ -0,0 +1,22 @@
+{
+  "name": "scheduler-service",
+  "version": "1.0.0",
+  "description": "Scheduled post and task management service",
+  "main": "index.js",
+  "scripts": {
+    "start": "node index.js",
+    "dev": "nodemon index.js"
+  },
+  "dependencies": {
+    "fastify": "^4.24.3",
+    "bullmq": "^5.1.0",
+    "ioredis": "^5.3.2",
+    "axios": "^1.6.2",
+    "amqplib": "^0.10.3",
+    "mongodb": "^6.3.0",
+    "dotenv": "^16.3.1"
+  },
+  "devDependencies": {
+    "nodemon": "^3.0.2"
+  }
+}

+ 6 - 9
services/twitter/dockerfile

@@ -1,10 +1,7 @@
-FROM node:current-alpine3.17
-
-COPY package*.json ./
-RUN npm install
-
+FROM node:20-alpine
 WORKDIR /services/twitter
-
-COPY . .
-
-CMD [ "npm", "start" ]
+COPY twitter/package*.json ./
+RUN npm install
+COPY utils ./utils
+COPY twitter/ .
+CMD ["node", "index.js"]

+ 136 - 8
services/twitter/index.js

@@ -1,8 +1,136 @@
-const RabbitMQListener = require('./utils/RabbitMQListener');
-const rabbitMQListener = new RabbitMQListener();
-
-(async () => {
-  await rabbitMQListener.listenToQueue('twitter', (message) => {
-    console.log('Received message:', message);
-  });
-})();
+require('dotenv').config();
+const { TwitterApi } = require('twitter-api-v2');
+const BasePlatformService = require('./utils/BasePlatformService');
+const { getDb } = require('./utils/MongoDBConnector');
+
+const {
+  TWITTER_API_KEY,
+  TWITTER_API_SECRET,
+  TWITTER_ACCESS_TOKEN,
+  TWITTER_ACCESS_SECRET,
+  TWITTER_BEARER_TOKEN,
+} = process.env;
+
+class TwitterService extends BasePlatformService {
+  constructor() {
+    super('twitter');
+    this.client = null;
+    this.roClient = null;
+  }
+
+  _initClients() {
+    if (!TWITTER_BEARER_TOKEN && !TWITTER_API_KEY) return;
+
+    // Read-only client (Bearer token — feed okuma için)
+    if (TWITTER_BEARER_TOKEN) {
+      this.roClient = new TwitterApi(TWITTER_BEARER_TOKEN);
+    }
+
+    // Read-write client (OAuth 1.0a — tweet atmak için)
+    if (TWITTER_API_KEY && TWITTER_API_SECRET && TWITTER_ACCESS_TOKEN && TWITTER_ACCESS_SECRET) {
+      this.client = new TwitterApi({
+        appKey: TWITTER_API_KEY,
+        appSecret: TWITTER_API_SECRET,
+        accessToken: TWITTER_ACCESS_TOKEN,
+        accessSecret: TWITTER_ACCESS_SECRET,
+      });
+    }
+  }
+
+  async getStatus() {
+    this._initClients();
+    if (!this.client) {
+      return { connected: false, platform: 'twitter', error: 'Twitter credentials not configured' };
+    }
+    try {
+      const me = await this.client.v2.me();
+      return {
+        connected: true,
+        platform: 'twitter',
+        userId: me.data.id,
+        username: me.data.username,
+        displayName: me.data.name,
+      };
+    } catch (err) {
+      return { connected: false, platform: 'twitter', error: err.message };
+    }
+  }
+
+  async fetchFeed({ limit = 20 } = {}) {
+    this._initClients();
+    const client = this.client || this.roClient;
+    if (!client) throw new Error('Twitter credentials not configured');
+
+    // Home timeline (OAuth 1.0a gerektirir)
+    const me = await this.client.v2.me();
+    const timeline = await this.client.v2.homeTimeline({
+      max_results: Math.min(Number(limit), 100),
+      'tweet.fields': ['created_at', 'public_metrics', 'author_id', 'entities'],
+      expansions: ['author_id', 'attachments.media_keys'],
+      'user.fields': ['name', 'username', 'profile_image_url'],
+      'media.fields': ['url', 'preview_image_url', 'alt_text', 'type'],
+    });
+
+    const users = {};
+    const media = {};
+    (timeline.includes?.users || []).forEach((u) => (users[u.id] = u));
+    (timeline.includes?.media || []).forEach((m) => (media[m.media_key] = m));
+
+    const items = (timeline.data?.data || []).map((tweet) => {
+      const author = users[tweet.author_id] || {};
+      const tweetMedia = (tweet.attachments?.media_keys || [])
+        .map((key) => media[key])
+        .filter(Boolean)
+        .map((m) => ({ url: m.url || m.preview_image_url, type: m.type, alt: m.alt_text }));
+
+      return this.normalizeFeedItem({
+        originalId: tweet.id,
+        author: {
+          name: author.name || '',
+          username: author.username || '',
+          avatar: author.profile_image_url,
+          profileUrl: `https://twitter.com/${author.username}`,
+        },
+        content: tweet.text,
+        media: tweetMedia,
+        platformTags: (tweet.entities?.hashtags || []).map((h) => h.tag),
+        metrics: {
+          likes: tweet.public_metrics?.like_count || 0,
+          shares: tweet.public_metrics?.retweet_count || 0,
+          comments: tweet.public_metrics?.reply_count || 0,
+          views: tweet.public_metrics?.impression_count || 0,
+        },
+        url: `https://twitter.com/${author.username}/status/${tweet.id}`,
+        createdAt: tweet.created_at,
+      });
+    });
+
+    // MongoDB'ye kaydet (upsert)
+    try {
+      const db = await getDb();
+      const col = db.collection('feeds');
+      for (const item of items) {
+        await col.updateOne(
+          { platform: 'twitter', originalId: item.originalId },
+          { $set: item },
+          { upsert: true }
+        );
+      }
+    } catch (err) {
+      console.error('[Twitter] MongoDB write error:', err.message);
+    }
+
+    return items;
+  }
+
+  async publishPost({ content } = {}) {
+    this._initClients();
+    if (!this.client) throw new Error('Twitter write credentials not configured');
+
+    const result = await this.client.v2.tweet(content);
+    return { id: result.data.id, text: result.data.text };
+  }
+}
+
+const service = new TwitterService();
+service.start(process.env.PORT || 3001);

+ 21 - 14
services/twitter/package.json

@@ -1,16 +1,23 @@
 {
-    "name": "twitter-service",
-    "version": "1.0.0",
-    "description": "",
-    "main": "index.js",
-    "scripts": {
-        "start": "nodemon index.js"
-    },
-    "dependencies": {
-        "amqplib": "^0.10.3",
-        "nodemon": "^3.0.1"
-    },
-    "engines": {
-        "node": ">=12.0.0"
-    }
+  "name": "twitter-service",
+  "version": "1.0.0",
+  "description": "Twitter/X platform service",
+  "main": "index.js",
+  "scripts": {
+    "start": "node index.js",
+    "dev": "nodemon index.js"
+  },
+  "dependencies": {
+    "twitter-api-v2": "^1.17.0",
+    "fastify": "^4.24.3",
+    "amqplib": "^0.10.3",
+    "mongodb": "^6.3.0",
+    "dotenv": "^16.3.1"
+  },
+  "devDependencies": {
+    "nodemon": "^3.0.2"
+  },
+  "engines": {
+    "node": ">=18.0.0"
+  }
 }

+ 131 - 0
services/utils/BasePlatformService.js

@@ -0,0 +1,131 @@
+const Fastify = require('fastify');
+const RabbitMQConnector = require('./RabbitMQConnector');
+
+/**
+ * BasePlatformService — tüm platform servisleri bu sınıftan extend eder.
+ *
+ * Her platform servisi şu metodları override etmeli:
+ *   - fetchFeed()        → platform API'sinden feed çeker
+ *   - publishPost(post)  → platforma içerik gönderir
+ *   - getStatus()        → bağlantı durumu döner
+ *   - authenticate(code) → OAuth callback işler
+ */
+class BasePlatformService extends RabbitMQConnector {
+  constructor(platformName) {
+    super();
+    this.platformName = platformName;
+    this.app = Fastify({ logger: false });
+    this._setupRoutes();
+  }
+
+  /** HTTP route'ları kaydet — platform servisleri override edebilir */
+  _setupRoutes() {
+    this.app.get('/status', async () => {
+      return this.getStatus();
+    });
+
+    this.app.get('/feed', async (request, reply) => {
+      try {
+        const items = await this.fetchFeed(request.query);
+        return { success: true, platform: this.platformName, count: items.length, items };
+      } catch (err) {
+        reply.code(500).send({ success: false, error: err.message });
+      }
+    });
+
+    this.app.post('/post', async (request, reply) => {
+      try {
+        const result = await this.publishPost(request.body);
+        return { success: true, platform: this.platformName, result };
+      } catch (err) {
+        reply.code(500).send({ success: false, error: err.message });
+      }
+    });
+
+    this.app.get('/auth/callback', async (request, reply) => {
+      try {
+        const result = await this.authenticate(request.query);
+        return { success: true, platform: this.platformName, result };
+      } catch (err) {
+        reply.code(500).send({ success: false, error: err.message });
+      }
+    });
+  }
+
+  /** HTTP sunucusunu başlat */
+  async start(port = 3000) {
+    await this.connect();
+    await this.app.listen({ port, host: '0.0.0.0' });
+    console.log(`[${this.platformName}] Service started on port ${port}`);
+  }
+
+  // ─── Alt sınıfların override edeceği metodlar ───────────────────────────────
+
+  /** @returns {{ connected: boolean, platform: string, username?: string }} */
+  async getStatus() {
+    return { connected: false, platform: this.platformName };
+  }
+
+  /** @returns {Array<FeedItem>} normalize edilmiş feed öğeleri */
+  async fetchFeed() {
+    throw new Error(`[${this.platformName}] fetchFeed() implement edilmedi`);
+  }
+
+  /** @param {{ content: string, media?: Array, tags?: Array }} post */
+  async publishPost() {
+    throw new Error(`[${this.platformName}] publishPost() implement edilmedi`);
+  }
+
+  /** @param {{ code: string, state?: string }} query — OAuth callback params */
+  async authenticate() {
+    throw new Error(`[${this.platformName}] authenticate() implement edilmedi`);
+  }
+
+  // ─── Yardımcı metodlar ───────────────────────────────────────────────────────
+
+  /**
+   * Normalize edilmiş feed öğesi oluştur.
+   * Platform servisleri bu şablonu kullanarak veriyi standartlaştırır.
+   */
+  normalizeFeedItem({
+    originalId,
+    author,
+    content,
+    contentHtml = null,
+    media = [],
+    links = [],
+    platformTags = [],
+    metrics = {},
+    url = null,
+    createdAt = new Date(),
+  }) {
+    return {
+      platform: this.platformName,
+      originalId: String(originalId),
+      author: {
+        name: author.name || '',
+        username: author.username || '',
+        avatar: author.avatar || null,
+        profileUrl: author.profileUrl || null,
+      },
+      content,
+      contentHtml,
+      media,
+      links,
+      tags: [],
+      platformTags,
+      metrics: {
+        likes: metrics.likes || 0,
+        comments: metrics.comments || 0,
+        shares: metrics.shares || 0,
+        views: metrics.views || 0,
+        bookmarks: metrics.bookmarks || 0,
+      },
+      url,
+      createdAt: new Date(createdAt),
+      fetchedAt: new Date(),
+    };
+  }
+}
+
+module.exports = BasePlatformService;

+ 33 - 0
services/utils/MongoDBConnector.js

@@ -0,0 +1,33 @@
+const { MongoClient } = require('mongodb');
+
+const MONGODB_URL = process.env.MONGODB_URL || 'mongodb://mongodb:27017';
+const DB_NAME = process.env.MONGODB_DB || 'socialmedia';
+
+let client = null;
+let db = null;
+
+async function connect() {
+  if (db) return db;
+
+  client = new MongoClient(MONGODB_URL);
+  await client.connect();
+  db = client.db(DB_NAME);
+  console.log(`[MongoDB] Connected to ${DB_NAME}`);
+  return db;
+}
+
+async function getDb() {
+  if (!db) await connect();
+  return db;
+}
+
+async function disconnect() {
+  if (client) {
+    await client.close();
+    client = null;
+    db = null;
+    console.log('[MongoDB] Disconnected');
+  }
+}
+
+module.exports = { connect, getDb, disconnect };

+ 5 - 1
ui/package.json

@@ -15,7 +15,11 @@
     "@fortawesome/vue-fontawesome": "^3.0.3",
     "axios": "^1.4.0",
     "socket.io-client": "^4.7.2",
-    "vue": "^3.3.4"
+    "vue": "^3.3.4",
+    "vue-router": "^4.2.5",
+    "pinia": "^2.1.7",
+    "dayjs": "^1.11.10",
+    "vue-i18n": "^9.8.0"
   },
   "devDependencies": {
     "@vitejs/plugin-vue": "^4.2.3",

+ 9 - 12
ui/src/App.vue

@@ -1,15 +1,12 @@
-<script setup lang="ts">
-import PostSender from './components/PostSender.vue'
-import NavBar from './components/NavBar.vue'
-import Footer from './components/Footer.vue';
-</script>
-
 <template>
-  <NavBar/>
-  <PostSender/>
-  <Footer/>
+  <div class="min-h-screen bg-gray-950 flex flex-col">
+    <NavBar />
+    <div class="flex-1">
+      <RouterView />
+    </div>
+  </div>
 </template>
 
-<style scoped>
-
-</style>
+<script setup lang="ts">
+import NavBar from './components/NavBar.vue'
+</script>

+ 78 - 6
ui/src/components/NavBar.vue

@@ -1,7 +1,79 @@
 <template>
-    <nav class="bg-blue-500 p-4">
-        <div class="container mx-auto flex justify-between items-center">
-          <a href="#" class="text-white font-semibold text-lg">Social Media Manager</a>
-        </div>
-    </nav>
-</template>
+  <nav class="bg-gray-900 border-b border-gray-800 px-6 py-3 flex items-center justify-between">
+    <router-link to="/dashboard" class="text-white font-bold text-lg tracking-tight">
+      📡 SocialManager
+    </router-link>
+
+    <div class="flex items-center gap-1">
+      <router-link
+        v-for="link in navLinks"
+        :key="link.to"
+        :to="link.to"
+        class="px-4 py-2 rounded-lg text-sm font-medium transition-colors"
+        :class="$route.path === link.to
+          ? 'bg-gray-800 text-white'
+          : 'text-gray-400 hover:text-white hover:bg-gray-800'"
+      >
+        {{ $t(link.label) }}
+      </router-link>
+    </div>
+
+    <!-- Dil seçici -->
+    <div class="relative">
+      <button
+        @click="showLangMenu = !showLangMenu"
+        class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm text-gray-400 hover:text-white hover:bg-gray-800 transition-colors"
+      >
+        <span>{{ currentLocale.flag }}</span>
+        <span>{{ currentLocale.code.toUpperCase() }}</span>
+        <span class="text-xs opacity-50">▾</span>
+      </button>
+
+      <div
+        v-if="showLangMenu"
+        class="absolute right-0 top-full mt-1 bg-gray-800 border border-gray-700 rounded-xl overflow-hidden shadow-xl z-50 min-w-[140px]"
+      >
+        <button
+          v-for="loc in SUPPORTED_LOCALES"
+          :key="loc.code"
+          @click="setLocale(loc.code)"
+          class="flex items-center gap-2 w-full px-4 py-2.5 text-sm transition-colors hover:bg-gray-700"
+          :class="locale === loc.code ? 'text-white font-medium' : 'text-gray-400'"
+        >
+          <span>{{ loc.flag }}</span>
+          <span>{{ loc.label }}</span>
+          <span v-if="locale === loc.code" class="ml-auto text-blue-400 text-xs">✓</span>
+        </button>
+      </div>
+    </div>
+  </nav>
+
+  <!-- Overlay to close menu -->
+  <div v-if="showLangMenu" class="fixed inset-0 z-40" @click="showLangMenu = false" />
+</template>
+
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import { useI18n } from 'vue-i18n'
+import { SUPPORTED_LOCALES } from '../locales'
+
+const { locale } = useI18n()
+const showLangMenu = ref(false)
+
+const navLinks = [
+  { to: '/dashboard', label: 'nav.feed' },
+  { to: '/compose',   label: 'nav.compose' },
+  { to: '/scheduler', label: 'nav.scheduler' },
+  { to: '/settings',  label: 'nav.settings' },
+]
+
+const currentLocale = computed(
+  () => SUPPORTED_LOCALES.find((l) => l.code === locale.value) ?? SUPPORTED_LOCALES[0]
+)
+
+function setLocale(code: string) {
+  locale.value = code
+  localStorage.setItem('locale', code)
+  showLangMenu.value = false
+}
+</script>

+ 85 - 0
ui/src/components/feed/FeedItem.vue

@@ -0,0 +1,85 @@
+<template>
+  <article class="bg-gray-900 border border-gray-800 rounded-xl p-4 hover:border-gray-700 transition-colors">
+    <div class="flex items-start gap-3 mb-3">
+      <img
+        v-if="item.author.avatar"
+        :src="item.author.avatar"
+        :alt="item.author.name"
+        class="w-10 h-10 rounded-full object-cover flex-shrink-0"
+      />
+      <div v-else class="w-10 h-10 rounded-full bg-gray-700 flex items-center justify-center flex-shrink-0 text-lg">
+        {{ item.author.name?.[0]?.toUpperCase() || '?' }}
+      </div>
+      <div class="flex-1 min-w-0">
+        <div class="flex items-center gap-2 flex-wrap">
+          <span class="font-medium text-sm text-white truncate">{{ item.author.name }}</span>
+          <span class="text-gray-500 text-xs truncate">@{{ item.author.username }}</span>
+          <span
+            class="ml-auto text-xs font-medium px-2 py-0.5 rounded-full flex-shrink-0"
+            :style="{ backgroundColor: platformColor + '22', color: platformColor }"
+          >
+            {{ $t(`platforms.${item.platform}`) }}
+          </span>
+        </div>
+        <p class="text-xs text-gray-500 mt-0.5">{{ timeAgo }}</p>
+      </div>
+    </div>
+
+    <p class="text-sm text-gray-200 leading-relaxed whitespace-pre-wrap break-words mb-3">{{ item.content }}</p>
+
+    <div v-if="item.media?.length" class="grid gap-2 mb-3" :class="item.media.length > 1 ? 'grid-cols-2' : 'grid-cols-1'">
+      <img
+        v-for="(m, i) in item.media.slice(0, 4)"
+        :key="i"
+        :src="m.thumbnail || m.url"
+        :alt="m.alt || ''"
+        class="rounded-lg w-full h-40 object-cover"
+      />
+    </div>
+
+    <div v-if="item.platformTags?.length" class="flex flex-wrap gap-1 mb-3">
+      <span
+        v-for="tag in item.platformTags.slice(0, 5)"
+        :key="tag"
+        class="text-xs text-blue-400 hover:text-blue-300 cursor-pointer"
+      >#{{ tag }}</span>
+    </div>
+
+    <div class="flex items-center gap-5 text-xs text-gray-500">
+      <span v-if="item.metrics.likes">❤️ {{ formatNum(item.metrics.likes) }}</span>
+      <span v-if="item.metrics.comments">💬 {{ formatNum(item.metrics.comments) }}</span>
+      <span v-if="item.metrics.shares">🔁 {{ formatNum(item.metrics.shares) }}</span>
+      <a
+        v-if="item.url"
+        :href="item.url"
+        target="_blank"
+        rel="noopener noreferrer"
+        class="ml-auto hover:text-gray-300 transition-colors"
+      >{{ $t('feed.openOriginal') }}</a>
+    </div>
+  </article>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import { useI18n } from 'vue-i18n'
+import dayjs from 'dayjs'
+import relativeTime from 'dayjs/plugin/relativeTime'
+import 'dayjs/locale/tr'
+import 'dayjs/locale/en'
+import { PLATFORM_META } from '../../stores/platforms'
+import type { FeedItem } from '../../stores/feed'
+
+dayjs.extend(relativeTime)
+
+const { locale } = useI18n()
+const props = defineProps<{ item: FeedItem }>()
+
+const platformColor = computed(() => PLATFORM_META[props.item.platform]?.color ?? '#6b7280')
+const timeAgo = computed(() => dayjs(props.item.createdAt).locale(locale.value).fromNow())
+
+function formatNum(n: number): string {
+  if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
+  return String(n)
+}
+</script>

+ 70 - 0
ui/src/locales/en.ts

@@ -0,0 +1,70 @@
+export default {
+  nav: {
+    feed: 'Feed',
+    compose: 'New Post',
+    scheduler: 'Scheduler',
+    settings: 'Settings',
+  },
+
+  dashboard: {
+    platforms: 'Platforms',
+    tags: 'Tags',
+    allTags: 'All',
+    searchPlaceholder: 'Search content or user...',
+    refresh: 'Refresh',
+    refreshing: 'Refreshing...',
+    newPost: '+ New Post',
+    loading: 'Loading feed...',
+    empty: 'No content to show.',
+    emptyHint: 'Check platform connections or refresh the feed.',
+  },
+
+  compose: {
+    title: 'New Post',
+    platformsLabel: 'Share to platforms',
+    placeholder: "What's on your mind?",
+    schedulingLabel: 'Schedule (optional)',
+    cancel: 'Cancel',
+    schedule: 'Schedule',
+    scheduling: 'Scheduling...',
+    send: 'Post →',
+    sending: 'Posting...',
+    successMessage: 'Post sent successfully.',
+  },
+
+  scheduler: {
+    title: 'Scheduler',
+    newSchedule: '+ New Schedule',
+    noJobs: 'No scheduled posts.',
+    statuses: {
+      pending: 'Pending',
+      completed: 'Completed',
+      failed: 'Failed',
+      cancelled: 'Cancelled',
+    },
+    cancel: 'Cancel',
+  },
+
+  settings: {
+    title: 'Platform Connections',
+    subtitle: 'Edit the {env} file to connect platforms, then restart the relevant service.',
+    connected: 'Connected',
+    notConnected: 'Not connected',
+    refreshStatus: '↻ Refresh Status',
+    envHint: 'Configuration required',
+  },
+
+  feed: {
+    openOriginal: '↗ Open',
+  },
+
+  platforms: {
+    twitter: 'Twitter/X',
+    linkedin: 'LinkedIn',
+    mastodon: 'Mastodon',
+    bluesky: 'Bluesky',
+    instagram: 'Instagram',
+    reddit: 'Reddit',
+    youtube: 'YouTube',
+  },
+}

+ 24 - 0
ui/src/locales/index.ts

@@ -0,0 +1,24 @@
+import { createI18n } from 'vue-i18n'
+import en from './en'
+import tr from './tr'
+
+// Yeni dil eklemek için:
+// 1. src/locales/xx.ts dosyası oluştur (en.ts'yi kopyala ve çevir)
+// 2. Aşağıya import et ve messages'a ekle
+// 3. SUPPORTED_LOCALES listesine ekle
+
+export const SUPPORTED_LOCALES = [
+  { code: 'en', label: 'English', flag: '🇬🇧' },
+  { code: 'tr', label: 'Türkçe', flag: '🇹🇷' },
+]
+
+const savedLocale = localStorage.getItem('locale') || 'en'
+
+const i18n = createI18n({
+  legacy: false,
+  locale: savedLocale,
+  fallbackLocale: 'en',
+  messages: { en, tr },
+})
+
+export default i18n

+ 70 - 0
ui/src/locales/tr.ts

@@ -0,0 +1,70 @@
+export default {
+  nav: {
+    feed: 'Akış',
+    compose: 'Yeni Gönderi',
+    scheduler: 'Zamanlama',
+    settings: 'Ayarlar',
+  },
+
+  dashboard: {
+    platforms: 'Platformlar',
+    tags: 'Etiketler',
+    allTags: 'Tümü',
+    searchPlaceholder: 'İçerik veya kullanıcı ara...',
+    refresh: 'Yenile',
+    refreshing: 'Yenileniyor...',
+    newPost: '+ Yeni Gönderi',
+    loading: 'Feed yükleniyor...',
+    empty: 'Gösterilecek içerik yok.',
+    emptyHint: 'Platform bağlantılarını kontrol et veya feed\'i yenile.',
+  },
+
+  compose: {
+    title: 'Yeni Gönderi',
+    platformsLabel: 'Paylaşılacak platformlar',
+    placeholder: 'Ne düşünüyorsun?',
+    schedulingLabel: 'Zamanlama (opsiyonel)',
+    cancel: 'İptal',
+    schedule: 'Zamanla',
+    scheduling: 'Zamanlanıyor...',
+    send: 'Gönder →',
+    sending: 'Gönderiliyor...',
+    successMessage: 'Gönderi başarıyla gönderildi.',
+  },
+
+  scheduler: {
+    title: 'Zamanlama',
+    newSchedule: '+ Yeni Zamanla',
+    noJobs: 'Zamanlanmış gönderi yok.',
+    statuses: {
+      pending: 'Bekleyen',
+      completed: 'Tamamlanan',
+      failed: 'Başarısız',
+      cancelled: 'İptal',
+    },
+    cancel: 'İptal',
+  },
+
+  settings: {
+    title: 'Platform Bağlantıları',
+    subtitle: 'Platforma bağlanmak için {env} dosyasını düzenle, ardından ilgili servisi yeniden başlat.',
+    connected: 'Bağlı',
+    notConnected: 'Bağlı değil',
+    refreshStatus: '↻ Durumları Yenile',
+    envHint: 'Yapılandırma gerekli',
+  },
+
+  feed: {
+    openOriginal: '↗ Aç',
+  },
+
+  platforms: {
+    twitter: 'Twitter/X',
+    linkedin: 'LinkedIn',
+    mastodon: 'Mastodon',
+    bluesky: 'Bluesky',
+    instagram: 'Instagram',
+    reddit: 'Reddit',
+    youtube: 'YouTube',
+  },
+}

+ 11 - 4
ui/src/main.ts

@@ -1,12 +1,19 @@
 import { createApp } from 'vue'
+import { createPinia } from 'pinia'
 import './style.css'
 import App from './App.vue'
+import router from './router'
+import i18n from './locales'
 import { library } from '@fortawesome/fontawesome-svg-core'
 import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
-import { faGithub } from '@fortawesome/free-brands-svg-icons';
+import { faGithub, faXTwitter, faLinkedin, faMastodon, faInstagram, faReddit, faYoutube } from '@fortawesome/free-brands-svg-icons'
+import { faCloud } from '@fortawesome/free-solid-svg-icons'
 
-library.add(faGithub)
+library.add(faGithub, faXTwitter, faLinkedin, faMastodon, faInstagram, faReddit, faYoutube, faCloud)
 
 createApp(App)
-.component('font-awesome-icon', FontAwesomeIcon)
-.mount('#app')
+  .use(createPinia())
+  .use(router)
+  .use(i18n)
+  .component('font-awesome-icon', FontAwesomeIcon)
+  .mount('#app')

+ 33 - 0
ui/src/router/index.ts

@@ -0,0 +1,33 @@
+import { createRouter, createWebHistory } from 'vue-router'
+
+const router = createRouter({
+  history: createWebHistory(),
+  routes: [
+    {
+      path: '/',
+      redirect: '/dashboard',
+    },
+    {
+      path: '/dashboard',
+      name: 'dashboard',
+      component: () => import('../views/Dashboard.vue'),
+    },
+    {
+      path: '/compose',
+      name: 'compose',
+      component: () => import('../views/Compose.vue'),
+    },
+    {
+      path: '/scheduler',
+      name: 'scheduler',
+      component: () => import('../views/Scheduler.vue'),
+    },
+    {
+      path: '/settings',
+      name: 'settings',
+      component: () => import('../views/Settings.vue'),
+    },
+  ],
+})
+
+export default router

+ 81 - 0
ui/src/stores/compose.ts

@@ -0,0 +1,81 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import axios from 'axios'
+
+export const useComposeStore = defineStore('compose', () => {
+  const content = ref('')
+  const selectedPlatforms = ref<string[]>(['twitter', 'mastodon', 'bluesky'])
+  const scheduledAt = ref<string>('')
+  const sending = ref(false)
+  const lastResult = ref<Record<string, unknown> | null>(null)
+
+  const CHAR_LIMITS: Record<string, number> = {
+    twitter: 280,
+    mastodon: 500,
+    bluesky: 300,
+    linkedin: 3000,
+    reddit: 40000,
+  }
+
+  function charLimit(platform: string): number {
+    return CHAR_LIMITS[platform] ?? 9999
+  }
+
+  function charCount(platform: string): number {
+    return content.value.length
+  }
+
+  function isOverLimit(platform: string): boolean {
+    return content.value.length > charLimit(platform)
+  }
+
+  function togglePlatform(platform: string) {
+    const idx = selectedPlatforms.value.indexOf(platform)
+    if (idx >= 0) {
+      selectedPlatforms.value.splice(idx, 1)
+    } else {
+      selectedPlatforms.value.push(platform)
+    }
+  }
+
+  async function sendNow() {
+    if (!content.value.trim() || !selectedPlatforms.value.length) return
+    sending.value = true
+    try {
+      const res = await axios.post('/api/', {
+        message: content.value,
+        platforms: selectedPlatforms.value,
+      })
+      lastResult.value = res.data
+      content.value = ''
+    } catch (err) {
+      console.error('Send error:', err)
+    } finally {
+      sending.value = false
+    }
+  }
+
+  async function schedulePost() {
+    if (!content.value.trim() || !selectedPlatforms.value.length || !scheduledAt.value) return
+    sending.value = true
+    try {
+      const res = await axios.post('/scheduler/schedule', {
+        content: content.value,
+        platforms: selectedPlatforms.value,
+        scheduledAt: scheduledAt.value,
+      })
+      lastResult.value = res.data
+      content.value = ''
+      scheduledAt.value = ''
+    } catch (err) {
+      console.error('Schedule error:', err)
+    } finally {
+      sending.value = false
+    }
+  }
+
+  return {
+    content, selectedPlatforms, scheduledAt, sending, lastResult,
+    charLimit, charCount, isOverLimit, togglePlatform, sendNow, schedulePost,
+  }
+})

+ 85 - 0
ui/src/stores/feed.ts

@@ -0,0 +1,85 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import axios from 'axios'
+
+export interface FeedItem {
+  _id?: string
+  platform: string
+  originalId: string
+  author: { name: string; username: string; avatar?: string; profileUrl?: string }
+  content: string
+  media: Array<{ url: string; type: string; thumbnail?: string; alt?: string }>
+  platformTags: string[]
+  tags: string[]
+  metrics: { likes: number; comments: number; shares: number; views: number }
+  url?: string
+  createdAt: string
+  fetchedAt: string
+}
+
+export const useFeedStore = defineStore('feed', () => {
+  const items = ref<FeedItem[]>([])
+  const loading = ref(false)
+  const activePlatforms = ref<Set<string>>(new Set(['twitter', 'mastodon', 'bluesky', 'linkedin']))
+  const activeTag = ref<string | null>(null)
+  const searchQuery = ref('')
+
+  const filteredItems = computed(() => {
+    return items.value.filter((item) => {
+      if (!activePlatforms.value.has(item.platform)) return false
+      if (activeTag.value && !item.tags.includes(activeTag.value)) return false
+      if (searchQuery.value) {
+        const q = searchQuery.value.toLowerCase()
+        return (
+          item.content.toLowerCase().includes(q) ||
+          item.author.username.toLowerCase().includes(q)
+        )
+      }
+      return true
+    })
+  })
+
+  async function fetchFeeds(platform?: string) {
+    loading.value = true
+    try {
+      const params: Record<string, string | number> = { limit: 100 }
+      if (platform) params.platform = platform
+      if (activeTag.value) params.tag = activeTag.value
+      const res = await axios.get('/feeds/feeds', { params })
+      items.value = res.data.items || []
+    } catch (err) {
+      console.error('Feed fetch error:', err)
+    } finally {
+      loading.value = false
+    }
+  }
+
+  async function refreshFeeds() {
+    try {
+      await axios.post('/feeds/fetch')
+      await fetchFeeds()
+    } catch (err) {
+      console.error('Refresh error:', err)
+    }
+  }
+
+  function addItem(item: FeedItem) {
+    const exists = items.value.some(
+      (i) => i.platform === item.platform && i.originalId === item.originalId
+    )
+    if (!exists) items.value.unshift(item)
+  }
+
+  function togglePlatform(platform: string) {
+    if (activePlatforms.value.has(platform)) {
+      activePlatforms.value.delete(platform)
+    } else {
+      activePlatforms.value.add(platform)
+    }
+  }
+
+  return {
+    items, loading, activePlatforms, activeTag, searchQuery,
+    filteredItems, fetchFeeds, refreshFeeds, addItem, togglePlatform,
+  }
+})

+ 49 - 0
ui/src/stores/platforms.ts

@@ -0,0 +1,49 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import axios from 'axios'
+
+export interface PlatformStatus {
+  platform: string
+  connected: boolean
+  username?: string
+  displayName?: string
+  avatar?: string
+  error?: string
+}
+
+export const PLATFORM_META: Record<string, { label: string; color: string; icon: string }> = {
+  twitter:  { label: 'Twitter/X',  color: '#000000', icon: 'fa-brands fa-x-twitter' },
+  linkedin: { label: 'LinkedIn',   color: '#0077B5', icon: 'fa-brands fa-linkedin' },
+  mastodon: { label: 'Mastodon',   color: '#6364FF', icon: 'fa-brands fa-mastodon' },
+  bluesky:  { label: 'Bluesky',    color: '#0085FF', icon: 'fa-solid fa-cloud' },
+  instagram:{ label: 'Instagram',  color: '#E1306C', icon: 'fa-brands fa-instagram' },
+  reddit:   { label: 'Reddit',     color: '#FF4500', icon: 'fa-brands fa-reddit' },
+  youtube:  { label: 'YouTube',    color: '#FF0000', icon: 'fa-brands fa-youtube' },
+}
+
+export const usePlatformsStore = defineStore('platforms', () => {
+  const statuses = ref<PlatformStatus[]>([])
+  const loading = ref(false)
+
+  async function fetchStatuses() {
+    loading.value = true
+    try {
+      const res = await axios.get('/feeds/platform-status')
+      statuses.value = res.data
+    } catch (err) {
+      console.error('Platform status error:', err)
+    } finally {
+      loading.value = false
+    }
+  }
+
+  function getStatus(platform: string): PlatformStatus | undefined {
+    return statuses.value.find((s) => s.platform === platform)
+  }
+
+  function isConnected(platform: string): boolean {
+    return getStatus(platform)?.connected ?? false
+  }
+
+  return { statuses, loading, fetchStatuses, getStatus, isConnected }
+})

+ 107 - 0
ui/src/views/Compose.vue

@@ -0,0 +1,107 @@
+<template>
+  <div class="min-h-screen bg-gray-950 text-gray-100 p-6">
+    <div class="max-w-2xl mx-auto">
+      <h1 class="text-2xl font-bold mb-6">{{ $t('compose.title') }}</h1>
+
+      <!-- Platform seçimi -->
+      <div class="bg-gray-900 border border-gray-800 rounded-xl p-4 mb-4">
+        <p class="text-sm text-gray-400 mb-3">{{ $t('compose.platformsLabel') }}</p>
+        <div class="flex flex-wrap gap-2">
+          <button
+            v-for="(meta, key) in PLATFORM_META"
+            :key="key"
+            @click="composeStore.togglePlatform(key)"
+            class="flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-all border"
+            :class="composeStore.selectedPlatforms.includes(key)
+              ? 'text-white border-transparent'
+              : 'text-gray-500 border-gray-700 hover:border-gray-500'"
+            :style="composeStore.selectedPlatforms.includes(key)
+              ? { backgroundColor: meta.color, borderColor: meta.color }
+              : {}"
+          >
+            {{ $t(`platforms.${key}`) }}
+            <span v-if="composeStore.selectedPlatforms.includes(key)" class="text-xs opacity-75">
+              {{ composeStore.charCount(key) }}/{{ composeStore.charLimit(key) }}
+            </span>
+          </button>
+        </div>
+      </div>
+
+      <!-- Editör -->
+      <div class="bg-gray-900 border border-gray-800 rounded-xl p-4 mb-4">
+        <textarea
+          v-model="composeStore.content"
+          :placeholder="$t('compose.placeholder')"
+          rows="6"
+          class="w-full bg-transparent text-gray-100 placeholder-gray-600 resize-none focus:outline-none text-sm leading-relaxed"
+        ></textarea>
+
+        <div v-if="composeStore.selectedPlatforms.length" class="flex flex-wrap gap-3 pt-3 border-t border-gray-800 mt-2">
+          <span
+            v-for="p in composeStore.selectedPlatforms"
+            :key="p"
+            class="text-xs"
+            :class="composeStore.isOverLimit(p) ? 'text-red-400' : 'text-gray-500'"
+          >
+            {{ $t(`platforms.${p}`) }}: {{ composeStore.charCount(p) }}/{{ composeStore.charLimit(p) }}
+          </span>
+        </div>
+      </div>
+
+      <!-- Zamanlama -->
+      <div class="bg-gray-900 border border-gray-800 rounded-xl p-4 mb-6">
+        <p class="text-sm text-gray-400 mb-2">{{ $t('compose.schedulingLabel') }}</p>
+        <input
+          v-model="composeStore.scheduledAt"
+          type="datetime-local"
+          class="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-gray-100 focus:outline-none focus:border-blue-500"
+        />
+      </div>
+
+      <!-- Butonlar -->
+      <div class="flex gap-3 justify-end">
+        <router-link to="/dashboard" class="px-4 py-2 text-sm text-gray-400 hover:text-gray-200 transition-colors">
+          {{ $t('compose.cancel') }}
+        </router-link>
+        <button
+          v-if="composeStore.scheduledAt"
+          @click="handleSchedule"
+          :disabled="composeStore.sending || !composeStore.content.trim()"
+          class="px-5 py-2 bg-amber-600 hover:bg-amber-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
+        >
+          {{ composeStore.sending ? $t('compose.scheduling') : `⏰ ${$t('compose.schedule')}` }}
+        </button>
+        <button
+          @click="handleSend"
+          :disabled="composeStore.sending || !composeStore.content.trim() || !composeStore.selectedPlatforms.length"
+          class="px-5 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
+        >
+          {{ composeStore.sending ? $t('compose.sending') : $t('compose.send') }}
+        </button>
+      </div>
+
+      <div v-if="composeStore.lastResult" class="mt-4 bg-green-900/30 border border-green-700 rounded-xl p-4 text-sm text-green-300">
+        {{ $t('compose.successMessage') }}
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useComposeStore } from '../stores/compose'
+import { PLATFORM_META } from '../stores/platforms'
+import { useRouter } from 'vue-router'
+
+const composeStore = useComposeStore()
+const router = useRouter()
+
+async function handleSend() {
+  await composeStore.sendNow()
+  if (composeStore.lastResult) setTimeout(() => router.push('/dashboard'), 1500)
+}
+
+async function handleSchedule() {
+  await composeStore.schedulePost()
+  if (composeStore.lastResult) setTimeout(() => router.push('/scheduler'), 1500)
+}
+</script>

+ 112 - 0
ui/src/views/Dashboard.vue

@@ -0,0 +1,112 @@
+<template>
+  <div class="flex h-screen overflow-hidden bg-gray-950 text-gray-100">
+
+    <!-- Sidebar -->
+    <aside class="w-60 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col p-4 gap-6 overflow-y-auto">
+      <div>
+        <p class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">
+          {{ $t('dashboard.platforms') }}
+        </p>
+        <button
+          v-for="(meta, key) in PLATFORM_META"
+          :key="key"
+          @click="feedStore.togglePlatform(key)"
+          class="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm transition-colors mb-1"
+          :class="feedStore.activePlatforms.has(key)
+            ? 'bg-gray-700 text-white'
+            : 'text-gray-500 hover:bg-gray-800 hover:text-gray-300'"
+        >
+          <span class="w-2 h-2 rounded-full flex-shrink-0" :style="{ backgroundColor: meta.color }"></span>
+          <span class="truncate">{{ $t(`platforms.${key}`) }}</span>
+          <span v-if="platformsStore.isConnected(key)" class="ml-auto w-1.5 h-1.5 rounded-full bg-green-400"></span>
+        </button>
+      </div>
+
+      <div>
+        <p class="text-xs font-semibold text-gray-500 uppercase tracking-widest mb-3">
+          {{ $t('dashboard.tags') }}
+        </p>
+        <button
+          @click="feedStore.activeTag = null"
+          class="flex items-center gap-2 w-full px-3 py-2 rounded-lg text-sm mb-1 transition-colors"
+          :class="!feedStore.activeTag ? 'bg-gray-700 text-white' : 'text-gray-500 hover:bg-gray-800'"
+        >
+          {{ $t('dashboard.allTags') }}
+        </button>
+      </div>
+    </aside>
+
+    <!-- Ana içerik -->
+    <main class="flex-1 flex flex-col overflow-hidden">
+      <header class="flex items-center gap-3 px-6 py-4 border-b border-gray-800 bg-gray-900">
+        <input
+          v-model="feedStore.searchQuery"
+          type="text"
+          :placeholder="$t('dashboard.searchPlaceholder')"
+          class="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-sm text-gray-100 placeholder-gray-500 focus:outline-none focus:border-blue-500"
+        />
+        <button
+          @click="handleRefresh"
+          :disabled="feedStore.loading"
+          class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
+        >
+          {{ feedStore.loading ? $t('dashboard.refreshing') : $t('dashboard.refresh') }}
+        </button>
+        <router-link
+          to="/compose"
+          class="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 rounded-lg text-sm font-medium transition-colors"
+        >
+          {{ $t('dashboard.newPost') }}
+        </router-link>
+      </header>
+
+      <div class="flex-1 overflow-y-auto px-6 py-4 space-y-4">
+        <div v-if="feedStore.loading && !feedStore.items.length" class="text-center text-gray-500 mt-20">
+          {{ $t('dashboard.loading') }}
+        </div>
+
+        <div v-else-if="!feedStore.filteredItems.length" class="text-center text-gray-500 mt-20">
+          <p class="text-4xl mb-4">📭</p>
+          <p>{{ $t('dashboard.empty') }}</p>
+          <p class="text-sm mt-1">{{ $t('dashboard.emptyHint') }}</p>
+        </div>
+
+        <FeedItem
+          v-for="item in feedStore.filteredItems"
+          :key="`${item.platform}-${item.originalId}`"
+          :item="item"
+        />
+      </div>
+    </main>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted } from 'vue'
+import { io } from 'socket.io-client'
+import { useFeedStore } from '../stores/feed'
+import { usePlatformsStore } from '../stores/platforms'
+import { PLATFORM_META } from '../stores/platforms'
+import FeedItem from '../components/feed/FeedItem.vue'
+
+const feedStore = useFeedStore()
+const platformsStore = usePlatformsStore()
+
+async function handleRefresh() {
+  await feedStore.refreshFeeds()
+}
+
+onMounted(async () => {
+  await Promise.all([
+    feedStore.fetchFeeds(),
+    platformsStore.fetchStatuses(),
+  ])
+
+  const socket = io()
+  socket.on('feed.items', (data: { platform: string; items: unknown[] }) => {
+    if (Array.isArray(data.items)) {
+      data.items.forEach((item) => feedStore.addItem(item as any))
+    }
+  })
+})
+</script>

+ 147 - 0
ui/src/views/Scheduler.vue

@@ -0,0 +1,147 @@
+<template>
+  <div class="min-h-screen bg-gray-950 text-gray-100 p-6">
+    <div class="max-w-3xl mx-auto">
+      <div class="flex items-center justify-between mb-6">
+        <h1 class="text-2xl font-bold">{{ $t('scheduler.title') }}</h1>
+        <router-link
+          to="/compose"
+          class="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm font-medium transition-colors"
+        >
+          {{ $t('scheduler.newSchedule') }}
+        </router-link>
+      </div>
+
+      <div class="flex gap-2 mb-6">
+        <button
+          v-for="s in statusOptions"
+          :key="s.value"
+          @click="activeStatus = s.value"
+          class="px-3 py-1.5 rounded-full text-sm font-medium transition-colors border"
+          :class="activeStatus === s.value
+            ? 'bg-gray-700 border-gray-600 text-white'
+            : 'border-gray-800 text-gray-500 hover:border-gray-600'"
+        >
+          {{ s.label }}
+        </button>
+      </div>
+
+      <div v-if="loading" class="text-center text-gray-500 mt-20">
+        {{ $t('dashboard.loading') }}
+      </div>
+
+      <div v-else-if="!jobs.length" class="text-center text-gray-500 mt-20">
+        <p class="text-4xl mb-4">📅</p>
+        <p>{{ $t('scheduler.noJobs') }}</p>
+      </div>
+
+      <div v-else class="space-y-3">
+        <div
+          v-for="job in jobs"
+          :key="job._id"
+          class="bg-gray-900 border border-gray-800 rounded-xl p-4"
+        >
+          <div class="flex items-start justify-between gap-4">
+            <div class="flex-1 min-w-0">
+              <p class="text-sm text-gray-200 line-clamp-2 mb-2">{{ job.postId }}</p>
+              <div class="flex flex-wrap gap-1 mb-2">
+                <span
+                  v-for="p in job.platforms"
+                  :key="p"
+                  class="text-xs px-2 py-0.5 rounded-full"
+                  :style="{ backgroundColor: platformColor(p) + '22', color: platformColor(p) }"
+                >
+                  {{ $t(`platforms.${p}`) }}
+                </span>
+              </div>
+              <p class="text-xs text-gray-500">{{ formatDate(job.scheduledAt) }}</p>
+            </div>
+            <div class="flex items-center gap-2 flex-shrink-0">
+              <span class="text-xs px-2 py-1 rounded-full font-medium" :class="statusClass(job.status)">
+                {{ $t(`scheduler.statuses.${job.status}`) }}
+              </span>
+              <button
+                v-if="job.status === 'pending'"
+                @click="cancelJob(job.bullJobId)"
+                class="text-xs text-red-400 hover:text-red-300 px-2 py-1 rounded transition-colors"
+              >
+                {{ $t('scheduler.cancel') }}
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted, watch } from 'vue'
+import { useI18n } from 'vue-i18n'
+import axios from 'axios'
+import dayjs from 'dayjs'
+import { PLATFORM_META } from '../stores/platforms'
+
+const { t } = useI18n()
+
+interface ScheduledJob {
+  _id: string
+  postId: string
+  platforms: string[]
+  scheduledAt: string
+  status: string
+  bullJobId: string
+}
+
+const jobs = ref<ScheduledJob[]>([])
+const loading = ref(false)
+const activeStatus = ref('pending')
+
+const statusOptions = computed(() => [
+  { value: 'pending',   label: t('scheduler.statuses.pending') },
+  { value: 'completed', label: t('scheduler.statuses.completed') },
+  { value: 'failed',    label: t('scheduler.statuses.failed') },
+  { value: 'cancelled', label: t('scheduler.statuses.cancelled') },
+])
+
+async function fetchJobs() {
+  loading.value = true
+  try {
+    const res = await axios.get(`/scheduler/jobs?status=${activeStatus.value}`)
+    jobs.value = res.data.jobs || []
+  } catch (err) {
+    console.error(err)
+  } finally {
+    loading.value = false
+  }
+}
+
+async function cancelJob(bullJobId: string) {
+  try {
+    await axios.delete(`/scheduler/jobs/${bullJobId}`)
+    jobs.value = jobs.value.filter((j) => j.bullJobId !== bullJobId)
+  } catch (err) {
+    console.error(err)
+  }
+}
+
+function formatDate(d: string) {
+  return dayjs(d).format('D MMM YYYY, HH:mm')
+}
+
+function platformColor(p: string) {
+  return PLATFORM_META[p]?.color ?? '#6b7280'
+}
+
+function statusClass(status: string) {
+  return {
+    pending:   'bg-yellow-900/40 text-yellow-400',
+    running:   'bg-blue-900/40 text-blue-400',
+    completed: 'bg-green-900/40 text-green-400',
+    failed:    'bg-red-900/40 text-red-400',
+    cancelled: 'bg-gray-800 text-gray-500',
+  }[status] ?? 'bg-gray-800 text-gray-400'
+}
+
+watch(activeStatus, fetchJobs)
+onMounted(fetchJobs)
+</script>

+ 80 - 0
ui/src/views/Settings.vue

@@ -0,0 +1,80 @@
+<template>
+  <div class="min-h-screen bg-gray-950 text-gray-100 p-6">
+    <div class="max-w-2xl mx-auto">
+      <h1 class="text-2xl font-bold mb-2">{{ $t('settings.title') }}</h1>
+      <p class="text-sm text-gray-500 mb-8">
+        {{ $t('settings.subtitle', { env: '.env' }) }}
+      </p>
+
+      <div class="space-y-4">
+        <div
+          v-for="(meta, key) in PLATFORM_META"
+          :key="key"
+          class="bg-gray-900 border rounded-xl p-4 transition-colors"
+          :class="isConnected(key) ? 'border-gray-700' : 'border-gray-800'"
+        >
+          <div class="flex items-center justify-between">
+            <div class="flex items-center gap-3">
+              <span
+                class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm"
+                :style="{ backgroundColor: meta.color }"
+              >
+                {{ meta.label[0] }}
+              </span>
+              <div>
+                <p class="font-medium text-sm">{{ $t(`platforms.${key}`) }}</p>
+                <p v-if="getStatus(key)?.username" class="text-xs text-gray-400">
+                  @{{ getStatus(key)?.username }}
+                </p>
+                <p v-else-if="getStatus(key)?.error" class="text-xs text-red-400">
+                  {{ getStatus(key)?.error }}
+                </p>
+                <p v-else class="text-xs text-gray-600">{{ $t('settings.notConnected') }}</p>
+              </div>
+            </div>
+            <div class="flex items-center gap-2">
+              <span class="w-2 h-2 rounded-full" :class="isConnected(key) ? 'bg-green-400' : 'bg-gray-600'"></span>
+              <span class="text-xs" :class="isConnected(key) ? 'text-green-400' : 'text-gray-600'">
+                {{ isConnected(key) ? $t('settings.connected') : $t('settings.notConnected') }}
+              </span>
+            </div>
+          </div>
+
+          <div v-if="!isConnected(key)" class="mt-3 bg-gray-800 rounded-lg p-3 text-xs text-gray-400 font-mono">
+            <span v-if="key === 'twitter'">TWITTER_API_KEY, TWITTER_API_SECRET, TWITTER_ACCESS_TOKEN, TWITTER_ACCESS_SECRET</span>
+            <span v-else-if="key === 'mastodon'">MASTODON_INSTANCE_URL, MASTODON_ACCESS_TOKEN</span>
+            <span v-else-if="key === 'bluesky'">BLUESKY_IDENTIFIER, BLUESKY_APP_PASSWORD</span>
+            <span v-else-if="key === 'linkedin'">LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET</span>
+            <span v-else-if="key === 'instagram'">INSTAGRAM_ACCESS_TOKEN</span>
+            <span v-else-if="key === 'reddit'">REDDIT_CLIENT_ID, REDDIT_CLIENT_SECRET, REDDIT_USERNAME, REDDIT_PASSWORD</span>
+            <span v-else>— {{ $t('settings.envHint') }} —</span>
+          </div>
+        </div>
+      </div>
+
+      <button
+        @click="platformsStore.fetchStatuses()"
+        class="mt-6 w-full py-2 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-sm transition-colors"
+      >
+        {{ $t('settings.refreshStatus') }}
+      </button>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { onMounted } from 'vue'
+import { usePlatformsStore, PLATFORM_META } from '../stores/platforms'
+
+const platformsStore = usePlatformsStore()
+
+function isConnected(platform: string) {
+  return platformsStore.isConnected(platform)
+}
+
+function getStatus(platform: string) {
+  return platformsStore.getStatus(platform)
+}
+
+onMounted(() => platformsStore.fetchStatuses())
+</script>