Claude güncellemesi Intranet güncellemesi

This commit is contained in:
Sedat ÖZTÜRK 2026-08-11 16:18:17 +03:00
parent 5217887bb8
commit de7e6c1e21
22 changed files with 649 additions and 1516 deletions

View file

@ -289,7 +289,6 @@ Seed edilen host yöneticisi ile `/login` üzerinden giriş yapılır (kullanıc
| `VITE_CDN_URL` | Dosya/CDN kök adresi. | | `VITE_CDN_URL` | Dosya/CDN kök adresi. |
| `VITE_REACT_APP_VERSION` | `package.json` sürümünden beslenir. | | `VITE_REACT_APP_VERSION` | `package.json` sürümünden beslenir. |
| `VITE_AI_URL` | AI asistanının n8n webhook kökü. | | `VITE_AI_URL` | AI asistanının n8n webhook kökü. |
| `VITE_GOOGLE_MAPS_API_KEY` | Harita bileşenleri için anahtar. |
| `VITE_USE_POLLING` | Dosya izlemede polling (WSL/Docker senaryoları). | | `VITE_USE_POLLING` | Dosya izlemede polling (WSL/Docker senaryoları). |
| `VITE_PWA_DEV` | Geliştirmede service worker'ı açar (varsayılan kapalı). | | `VITE_PWA_DEV` | Geliştirmede service worker'ı açar (varsayılan kapalı). |

View file

@ -6,11 +6,6 @@ public class CreateSocialPostInput
{ {
public string Content { get; set; } = string.Empty; public string Content { get; set; } = string.Empty;
/// <summary>
/// JSON string containing location data (name, address, lat, lng, placeId).
/// </summary>
public string? LocationJson { get; set; }
public CreateSocialPostMediaInput? Media { get; set; } public CreateSocialPostMediaInput? Media { get; set; }
} }

View file

@ -15,22 +15,11 @@ public class SocialPostDto : FullAuditedEntityDto<Guid>
public bool IsLiked { get; set; } public bool IsLiked { get; set; }
public bool IsOwnPost { get; set; } public bool IsOwnPost { get; set; }
public SocialLocationDto? Location { get; set; }
public SocialMediaDto? Media { get; set; } public SocialMediaDto? Media { get; set; }
public List<SocialCommentDto> Comments { get; set; } = []; public List<SocialCommentDto> Comments { get; set; } = [];
public List<SocialLikeDto> Likes { get; set; } = []; public List<SocialLikeDto> Likes { get; set; } = [];
} }
public class SocialLocationDto : FullAuditedEntityDto<Guid>
{
public Guid SocialPostId { get; set; }
public string Name { get; set; } = string.Empty;
public string? Address { get; set; }
public double? Lat { get; set; }
public double? Lng { get; set; }
public string? PlaceId { get; set; }
}
public class SocialMediaDto : FullAuditedEntityDto<Guid> public class SocialMediaDto : FullAuditedEntityDto<Guid>
{ {
public Guid SocialPostId { get; set; } public Guid SocialPostId { get; set; }

View file

@ -620,7 +620,7 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
// Sonra sadece bu ID'ler için detayları yükle // Sonra sadece bu ID'ler için detayları yükle
var queryable = await _socialPostRepository var queryable = await _socialPostRepository
.WithDetailsAsync(e => e.Location, e => e.Media, e => e.Comments, e => e.Likes); .WithDetailsAsync(e => e.Media, e => e.Comments, e => e.Likes);
var socialPosts = await AsyncExecuter.ToListAsync( var socialPosts = await AsyncExecuter.ToListAsync(
queryable queryable
@ -852,20 +852,6 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
Content = input.Content, Content = input.Content,
}; };
if (!string.IsNullOrWhiteSpace(input.LocationJson))
{
var locData = System.Text.Json.JsonSerializer.Deserialize<System.Text.Json.JsonElement>(input.LocationJson);
post.Location = new SocialLocation(Guid.NewGuid())
{
SocialPostId = post.Id,
Name = locData.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? string.Empty : string.Empty,
Address = locData.TryGetProperty("address", out var addrProp) ? addrProp.GetString() : null,
Lat = locData.TryGetProperty("lat", out var latProp) && latProp.TryGetDouble(out var latVal) ? latVal : null,
Lng = locData.TryGetProperty("lng", out var lngProp) && lngProp.TryGetDouble(out var lngVal) ? lngVal : null,
PlaceId = locData.TryGetProperty("placeId", out var placeIdProp) ? placeIdProp.GetString() : null,
};
}
if (input.Media != null) if (input.Media != null)
{ {
var media = new SocialMedia(Guid.NewGuid()) var media = new SocialMedia(Guid.NewGuid())
@ -895,7 +881,7 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
// Reload with full navigation properties for mapping // Reload with full navigation properties for mapping
var queryable = await _socialPostRepository var queryable = await _socialPostRepository
.WithDetailsAsync(e => e.Location, e => e.Media, e => e.Comments, e => e.Likes); .WithDetailsAsync(e => e.Media, e => e.Comments, e => e.Likes);
var savedPost = await AsyncExecuter.FirstOrDefaultAsync(queryable.Where(p => p.Id == post.Id)); var savedPost = await AsyncExecuter.FirstOrDefaultAsync(queryable.Where(p => p.Id == post.Id));
var dto = ObjectMapper.Map<SocialPost, SocialPostDto>(savedPost!); var dto = ObjectMapper.Map<SocialPost, SocialPostDto>(savedPost!);
@ -956,7 +942,7 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
await _socialPostRepository.UpdateAsync(post, autoSave: true); await _socialPostRepository.UpdateAsync(post, autoSave: true);
var queryable = await _socialPostRepository var queryable = await _socialPostRepository
.WithDetailsAsync(e => e.Location, e => e.Media, e => e.Comments, e => e.Likes); .WithDetailsAsync(e => e.Media, e => e.Comments, e => e.Likes);
var updated = await AsyncExecuter.FirstOrDefaultAsync(queryable.Where(p => p.Id == id)); var updated = await AsyncExecuter.FirstOrDefaultAsync(queryable.Where(p => p.Id == id));
var dto = ObjectMapper.Map<SocialPost, SocialPostDto>(updated!); var dto = ObjectMapper.Map<SocialPost, SocialPostDto>(updated!);

View file

@ -116,22 +116,6 @@ public partial class SocialPostToSocialPostDtoMapper : MapperBase<SocialPost, So
} }
} }
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
public partial class SocialLocationToSocialLocationDtoMapper : MapperBase<SocialLocation, SocialLocationDto>
{
public override partial SocialLocationDto Map(SocialLocation source);
public override partial void Map(SocialLocation source, SocialLocationDto destination);
public override void BeforeMap(SocialLocation source)
{
}
public override void AfterMap(SocialLocation source, SocialLocationDto destination)
{
}
}
[Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)] [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.Target)]
public partial class SocialMediaToSocialMediaDtoMapper : MapperBase<SocialMedia, SocialMediaDto> public partial class SocialMediaToSocialMediaDtoMapper : MapperBase<SocialMedia, SocialMediaDto>
{ {

View file

@ -12852,120 +12852,6 @@
"tr": "Tüm gönderiler yüklendi", "tr": "Tüm gönderiler yüklendi",
"en": "All posts loaded" "en": "All posts loaded"
}, },
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.LocationMap.OpenInGoogleMaps",
"en": "Open in Google Maps",
"tr": "Google Maps'te aç"
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.LocationMap.ClickForDirections",
"en": "Click to get directions",
"tr": "Yol tarifi almak için tıklayın"
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.LocationPicker.ApiKeyError",
"en": "Google Maps API key not found. Please add VITE_GOOGLE_MAPS_API_KEY to your .env file.",
"tr": "Google Maps API anahtarı bulunamadı. Lütfen .env dosyasına VITE_GOOGLE_MAPS_API_KEY ekleyin."
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.LocationPicker.GoogleMapsLoadError",
"en": "Google Maps could not be loaded. Please check your internet connection.",
"tr": "Google Maps yüklenemedi. Lütfen internet bağlantınızı kontrol edin."
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.LocationPicker.NoResults",
"en": "No results found",
"tr": "Sonuç bulunamadı"
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.LocationPicker.OverQueryLimit",
"en": "Google Places query limit exceeded. Please try again later.",
"tr": "Google Places sorgu limiti aşıldı. Lütfen daha sonra tekrar deneyiniz."
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.LocationPicker.RequestDenied",
"en": "Google Places request denied. Please check your API key, billing, or permissions settings.",
"tr": "Google Places isteği reddedildi. API key, billing veya yetki ayarlarını kontrol ediniz."
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.LocationPicker.InvalidRequest",
"en": "Invalid location search request. Please check your input and try again.",
"tr": "Geçersiz konum arama isteği."
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.LocationPicker.UnknownError",
"en": "Google Places returned a temporary error. Please try again.",
"tr": "Google Places geçici bir hata döndürdü. Lütfen tekrar deneyiniz."
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.LocationPicker.SearchFailed",
"en": "Location search failed",
"tr": "Konum arama başarısız"
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.LocationPicker.SearchError",
"en": "An error occurred during location search",
"tr": "Konum arama sırasında bir hata oluştu"
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.LocationPicker.AddLocation",
"en": "Add Location",
"tr": "Konum Ekle"
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.LocationPicker.SearchPlaceholder",
"en": "Search location...",
"tr": "Konum ara..."
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.LocationPicker.LoadingGoogleMaps",
"en": "Loading Google Maps...",
"tr": "Google Maps yükleniyor..."
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.LocationPicker.SearchingLocations",
"en": "Searching locations...",
"tr": "Konumlar aranıyor..."
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.LocationPicker.TypeToSearch",
"en": "Type the location you want to search",
"tr": "Aramak istediğiniz konumu yazın"
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.LocationPicker.Example",
"en": "e.g. Taksim, Istanbul",
"tr": "Örn: Taksim, İstanbul"
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.LocationPicker.NotFound",
"en": "Location not found. Try a different search.",
"tr": "Konum bulunamadı. Farklı bir arama yapın."
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.LocationPicker.SelectLocation",
"en": "Select a location",
"tr": "Bir konum seçin"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.MediaManager.AddMedia", "key": "App.Platform.Intranet.SocialWall.MediaManager.AddMedia",
@ -13068,6 +12954,30 @@
"en": "Write a comment...", "en": "Write a comment...",
"tr": "Yorum yazın..." "tr": "Yorum yazın..."
}, },
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.PostItem.VoteCount",
"en": "{count} votes",
"tr": "{count} oy"
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.PostItem.PollEnded",
"en": "Ended",
"tr": "Sona erdi"
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.PostItem.PollEndsIn",
"en": "ends in {time}",
"tr": "{time} içinde bitiyor"
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.PostItem.Fullscreen",
"en": "Fullscreen",
"tr": "Tam ekran"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.Platform.Intranet.Widgets.PriorityTasks.Title", "key": "App.Platform.Intranet.Widgets.PriorityTasks.Title",
@ -13248,18 +13158,6 @@
"tr": "Yanıtlar", "tr": "Yanıtlar",
"en": "Responses" "en": "Responses"
}, },
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.Widgets.ActiveSurveys.Duration",
"tr": "Süre",
"en": "Duration"
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.Widgets.ActiveSurveys.CompletionRate",
"tr": "Tamamlanma oranı",
"en": "Completion Rate"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.Platform.Intranet.Widgets.ActiveSurveys.FillSurvey", "key": "App.Platform.Intranet.Widgets.ActiveSurveys.FillSurvey",
@ -13644,12 +13542,6 @@
"tr": "Tüm medyaları kaldır", "tr": "Tüm medyaları kaldır",
"en": "Remove all media" "en": "Remove all media"
}, },
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.CreatePost.RemoveLocationTitle",
"tr": "Konumu kaldır",
"en": "Remove location"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.CreatePost.Poll", "key": "App.Platform.Intranet.SocialWall.CreatePost.Poll",
@ -13704,18 +13596,6 @@
"tr": "Emoji ekle", "tr": "Emoji ekle",
"en": "Add emoji" "en": "Add emoji"
}, },
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.CreatePost.EditLocationTitle",
"tr": "Konumu değiştir",
"en": "Edit location"
},
{
"resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.CreatePost.AddLocationTitle",
"tr": "Konum ekle",
"en": "Add location"
},
{ {
"resourceName": "Platform", "resourceName": "Platform",
"key": "App.Platform.Intranet.SocialWall.CreatePost.Submit", "key": "App.Platform.Intranet.SocialWall.CreatePost.Submit",

View file

@ -13,7 +13,7 @@ using Volo.Abp.EntityFrameworkCore;
namespace Sozsoft.Platform.Migrations namespace Sozsoft.Platform.Migrations
{ {
[DbContext(typeof(PlatformDbContext))] [DbContext(typeof(PlatformDbContext))]
[Migration("20260808210009_Initial")] [Migration("20260811131124_Initial")]
partial class Initial partial class Initial
{ {
/// <inheritdoc /> /// <inheritdoc />

View file

@ -63,7 +63,6 @@ public class TenantSeederDto
public List<SurveyQuestionSeedDto> SurveyQuestions { get; set; } public List<SurveyQuestionSeedDto> SurveyQuestions { get; set; }
public List<SurveyQuestionOptionSeedDto> SurveyQuestionOptions { get; set; } public List<SurveyQuestionOptionSeedDto> SurveyQuestionOptions { get; set; }
public List<SocialPostSeedDto> SocialPosts { get; set; } public List<SocialPostSeedDto> SocialPosts { get; set; }
public List<SocialLocationSeedDto> SocialLocations { get; set; }
public List<SocialMediaSeedDto> SocialMedias { get; set; } public List<SocialMediaSeedDto> SocialMedias { get; set; }
public List<SocialPollOptionSeedDto> SocialPollOptions { get; set; } public List<SocialPollOptionSeedDto> SocialPollOptions { get; set; }
public List<SocialCommentSeedDto> SocialComments { get; set; } public List<SocialCommentSeedDto> SocialComments { get; set; }
@ -97,16 +96,6 @@ public class SocialPostSeedDto
public bool IsOwnPost { get; set; } public bool IsOwnPost { get; set; }
} }
public class SocialLocationSeedDto
{
public string PostContent { get; set; }
public string Name { get; set; }
public string Address { get; set; }
public double? Lat { get; set; }
public double? Lng { get; set; }
public string PlaceId { get; set; }
}
public class SocialMediaSeedDto public class SocialMediaSeedDto
{ {
public string PostContent { get; set; } public string PostContent { get; set; }
@ -521,7 +510,6 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency
private readonly IRepository<SurveyQuestion, Guid> _surveyQuestionRepository; private readonly IRepository<SurveyQuestion, Guid> _surveyQuestionRepository;
private readonly IRepository<SurveyQuestionOption, Guid> _surveyQuestionOptionRepository; private readonly IRepository<SurveyQuestionOption, Guid> _surveyQuestionOptionRepository;
private readonly IRepository<SocialPost, Guid> _socialPostRepository; private readonly IRepository<SocialPost, Guid> _socialPostRepository;
private readonly IRepository<SocialLocation, Guid> _socialLocationRepository;
private readonly IRepository<SocialMedia, Guid> _socialMediaRepository; private readonly IRepository<SocialMedia, Guid> _socialMediaRepository;
private readonly IRepository<SocialPollOption, Guid> _socialPollOptionRepository; private readonly IRepository<SocialPollOption, Guid> _socialPollOptionRepository;
private readonly IRepository<SocialComment, Guid> _socialCommentRepository; private readonly IRepository<SocialComment, Guid> _socialCommentRepository;
@ -571,7 +559,6 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency
IRepository<SurveyQuestion, Guid> surveyQuestionRepository, IRepository<SurveyQuestion, Guid> surveyQuestionRepository,
IRepository<SurveyQuestionOption, Guid> surveyQuestionOptionRepository, IRepository<SurveyQuestionOption, Guid> surveyQuestionOptionRepository,
IRepository<SocialPost, Guid> socialPostRepository, IRepository<SocialPost, Guid> socialPostRepository,
IRepository<SocialLocation, Guid> socialLocationRepository,
IRepository<SocialMedia, Guid> socialMediaRepository, IRepository<SocialMedia, Guid> socialMediaRepository,
IRepository<SocialPollOption, Guid> socialPollOptionRepository, IRepository<SocialPollOption, Guid> socialPollOptionRepository,
IRepository<SocialComment, Guid> socialCommentRepository, IRepository<SocialComment, Guid> socialCommentRepository,
@ -620,7 +607,6 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency
_surveyQuestionRepository = surveyQuestionRepository; _surveyQuestionRepository = surveyQuestionRepository;
_surveyQuestionOptionRepository = surveyQuestionOptionRepository; _surveyQuestionOptionRepository = surveyQuestionOptionRepository;
_socialPostRepository = socialPostRepository; _socialPostRepository = socialPostRepository;
_socialLocationRepository = socialLocationRepository;
_socialMediaRepository = socialMediaRepository; _socialMediaRepository = socialMediaRepository;
_socialPollOptionRepository = socialPollOptionRepository; _socialPollOptionRepository = socialPollOptionRepository;
_socialCommentRepository = socialCommentRepository; _socialCommentRepository = socialCommentRepository;
@ -1162,28 +1148,6 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency
}, autoSave: true); }, autoSave: true);
} }
foreach (var item in items.SocialLocations)
{
var post = await _socialPostRepository.FirstOrDefaultAsync(x => x.Content == item.PostContent);
if (post == null)
continue;
var exists = await _socialLocationRepository.AnyAsync(x => x.SocialPostId == post.Id && x.Name == item.Name);
if (exists)
continue;
await _socialLocationRepository.InsertAsync(new SocialLocation(Guid.NewGuid())
{
SocialPostId = post != null ? post.Id : Guid.Empty,
Name = item.Name,
Address = item.Address,
Lat = item.Lat,
Lng = item.Lng,
PlaceId = item.PlaceId
}, autoSave: true);
}
foreach (var item in items.SocialMedias) foreach (var item in items.SocialMedias)
{ {
var post = await _socialPostRepository.FirstOrDefaultAsync(x => x.Content == item.PostContent); var post = await _socialPostRepository.FirstOrDefaultAsync(x => x.Content == item.PostContent);

18
ui/.env
View file

@ -2,21 +2,3 @@ VITE_API_URL='https://localhost:44344'
VITE_CDN_URL='http://localhost:4005' VITE_CDN_URL='http://localhost:4005'
VITE_REACT_APP_VERSION=$npm_package_version VITE_REACT_APP_VERSION=$npm_package_version
VITE_AI_URL='https://ai.sozsoft.com/webhook/' VITE_AI_URL='https://ai.sozsoft.com/webhook/'
VITE_GOOGLE_MAPS_API_KEY='AIzaSyAefS2rvF-xwq7OHpZ27UYxXPbMo6OwACc'
# Google Cloud Consoleda:
# APIs & Services > Enabled APIs & services bölümüne gir.
# Şunların aktif olduğundan emin ol:
# Maps JavaScript API
# Places API
# Eğer ileride koordinat/adres çözümleme yapıyorsan ayrıca:
# Geocoding API
# APIs & Services > Credentials > API Key içine gir.
# API restrictions bölümünde:
# Eğer Restrict key seçiliyse, izinli API listesine şunları ekle:
# Maps JavaScript API
# Places API

View file

@ -170,7 +170,6 @@ export interface SocialPostDto {
id: string id: string
user: UserInfoViewModel user: UserInfoViewModel
content: string content: string
locationJson?: string
media?: SocialMediaDto media?: SocialMediaDto
likeCount: number likeCount: number
isLiked: boolean isLiked: boolean

View file

@ -11,7 +11,6 @@ import apiService, { Config } from './api.service'
export interface CreateSocialPostInput { export interface CreateSocialPostInput {
content: string content: string
locationJson?: string
media?: { media?: {
type: 'image' | 'video' | 'poll' type: 'image' | 'video' | 'poll'
urls?: string[] urls?: string[]

View file

@ -23,7 +23,6 @@ import { useLocalization } from '@/utils/hooks/useLocalization'
import useLocale from '@/utils/hooks/useLocale' import useLocale from '@/utils/hooks/useLocale'
import { currentLocalDate } from '@/utils/dateUtils' import { currentLocalDate } from '@/utils/dateUtils'
import { useStoreActions, useStoreState } from '@/store/store' import { useStoreActions, useStoreState } from '@/store/store'
import type { DashboardLayout } from '@/store/admin.model'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import { LuX } from 'react-icons/lu' import { LuX } from 'react-icons/lu'
import type { DashboardWidgetColumn, DashboardWidgetDefinition } from './dashboardWidget' import type { DashboardWidgetColumn, DashboardWidgetDefinition } from './dashboardWidget'
@ -73,8 +72,6 @@ const layoutPresets = [
}, },
] as const ] as const
type DashboardLayoutId = DashboardLayout
const columnSpanClasses: Record<number, string> = { const columnSpanClasses: Record<number, string> = {
2: 'lg:col-span-2', 2: 'lg:col-span-2',
3: 'lg:col-span-3', 3: 'lg:col-span-3',
@ -105,10 +102,14 @@ const IntranetDashboard: React.FC = () => {
const currentLocale = useLocale() const currentLocale = useLocale()
const fetchIntranetDashboard = async () => { const fetchIntranetDashboard = async () => {
try {
const dashboard = await intranetService.getDashboard() const dashboard = await intranetService.getDashboard()
if (dashboard.data) { if (dashboard.data) {
setIntranetDashboard(dashboard.data) setIntranetDashboard(dashboard.data)
} }
} catch {
// hata apiService tarafından ele alınıyor
}
} }
useEffect(() => { useEffect(() => {
@ -199,35 +200,28 @@ const IntranetDashboard: React.FC = () => {
if (!grantedPolicies) return if (!grantedPolicies) return
const hasSavedOrder = dashboardColumns.some((column) => widgetOrder[column].length > 0) const hasSavedOrder = dashboardColumns.some((column) => widgetOrder[column].length > 0)
if (hasSavedOrder) { if (!hasSavedOrder) {
try { initializeDefaultOrder()
const parsed = widgetOrder return
}
// Kayıtlı sıralamayı tekilleştir ve henüz yerleştirilmemiş widget'ları ekle
const order: Record<DashboardWidgetColumn, string[]> = { const order: Record<DashboardWidgetColumn, string[]> = {
left: [...new Set((parsed.left || []) as string[])], left: [...new Set(widgetOrder.left || [])],
center: [...new Set((parsed.center || []) as string[])], center: [...new Set(widgetOrder.center || [])],
right: [...new Set((parsed.right || []) as string[])], right: [...new Set(widgetOrder.right || [])],
} }
const allAssigned = new Set([...order.left, ...order.center, ...order.right]) const allAssigned = new Set([...order.left, ...order.center, ...order.right])
dashboardWidgets.forEach((w) => { dashboardWidgets.forEach((widget) => {
if (!allAssigned.has(w.id) && checkPermission(w.permission)) { if (!allAssigned.has(widget.id) && checkPermission(widget.permission)) {
order[w.column as keyof typeof order].push(w.id) order[widget.column].push(widget.id)
} }
}) })
setWidgetOrder(order) setWidgetOrder(order)
} catch {
initializeDefaultOrder()
}
} else {
initializeDefaultOrder()
}
}, [grantedPolicies]) }, [grantedPolicies])
const saveWidgetOrder = (newOrder: Record<DashboardWidgetColumn, string[]>) => {
setWidgetOrder(newOrder)
}
const setWidgetVisibility = (widgetId: string, visible: boolean) => { const setWidgetVisibility = (widgetId: string, visible: boolean) => {
const next = visible const next = visible
? hiddenWidgetIds.filter((id) => id !== widgetId) ? hiddenWidgetIds.filter((id) => id !== widgetId)
@ -235,10 +229,6 @@ const IntranetDashboard: React.FC = () => {
setHiddenWidgetIds(next) setHiddenWidgetIds(next)
} }
const selectLayout = (layoutId: DashboardLayoutId) => {
setDashboardLayout(layoutId)
}
const activeLayout = const activeLayout =
layoutPresets.find((layout) => layout.id === selectedLayout) || layoutPresets[0] layoutPresets.find((layout) => layout.id === selectedLayout) || layoutPresets[0]
const hiddenWidgets = dashboardWidgets.filter( const hiddenWidgets = dashboardWidgets.filter(
@ -295,7 +285,7 @@ const IntranetDashboard: React.FC = () => {
newOrder[targetColumn].push(widgetId) newOrder[targetColumn].push(widgetId)
} }
saveWidgetOrder(newOrder) setWidgetOrder(newOrder)
setDragState({ draggedId: null, targetColumn: null, targetIndex: null }) setDragState({ draggedId: null, targetColumn: null, targetIndex: null })
} }
@ -532,7 +522,7 @@ const IntranetDashboard: React.FC = () => {
type="button" type="button"
variant="plain" variant="plain"
shape="none" shape="none"
onClick={() => selectLayout(layout.id)} onClick={() => setDashboardLayout(layout.id)}
className={`!h-8 !rounded-md !px-2 ${selectedLayout === layout.id ? '!bg-blue-600 text-white hover:!bg-blue-600' : '!bg-gray-100 text-gray-600 hover:!bg-gray-200 dark:!bg-gray-700 dark:text-gray-200'}`} className={`!h-8 !rounded-md !px-2 ${selectedLayout === layout.id ? '!bg-blue-600 text-white hover:!bg-blue-600' : '!bg-gray-100 text-gray-600 hover:!bg-gray-200 dark:!bg-gray-700 dark:text-gray-200'}`}
title={`${translate(layout.labelKey)} (${layout.columns.join(' / ')})`} title={`${translate(layout.labelKey)} (${layout.columns.join(' / ')})`}
aria-label={`${translate(layout.labelKey)} (${layout.columns.join(' / ')})`} aria-label={`${translate(layout.labelKey)} (${layout.columns.join(' / ')})`}

View file

@ -1,20 +1,21 @@
import React, { useState, useRef } from 'react' import React, { useEffect, useMemo, useRef, useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import classNames from 'classnames' import classNames from 'classnames'
import type { EmojiClickData, Theme } from 'emoji-picker-react' import type { EmojiClickData, Theme } from 'emoji-picker-react'
import { FaChartBar, FaSmile, FaTimes, FaImages, FaMapMarkerAlt } from 'react-icons/fa' import { FaChartBar, FaSmile, FaTimes, FaImages } from 'react-icons/fa'
import MediaManager from './MediaManager' import MediaManager from './MediaManager'
import LocationPicker from './LocationPicker'
import { SocialMediaDto } from '@/proxy/intranet/models' import { SocialMediaDto } from '@/proxy/intranet/models'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { useStoreState } from '@/store/store' import { useStoreState } from '@/store/store'
import { Avatar, Button } from '@/components/ui' import { Avatar, Button } from '@/components/ui'
import { AVATAR_URL } from '@/constants/app.constant' import { AVATAR_URL } from '@/constants/app.constant'
const MAX_POLL_OPTIONS = 6
const MIN_POLL_OPTIONS = 2
interface CreatePostProps { interface CreatePostProps {
onCreatePost: (post: { onCreatePost: (post: {
content: string content: string
location?: string
media?: { media?: {
type: 'mixed' | 'poll' type: 'mixed' | 'poll'
mediaItems?: SocialMediaDto[] mediaItems?: SocialMediaDto[]
@ -28,103 +29,97 @@ interface CreatePostProps {
const EmojiPicker = React.lazy(() => import('emoji-picker-react')) const EmojiPicker = React.lazy(() => import('emoji-picker-react'))
/** Bir sonraki tıklama hedefini temizleyen link/plain buton stilleri */
const LINK_BUTTON_CLASS =
'!h-auto !rounded-none !px-0 !py-0 text-sm font-medium hover:!bg-transparent active:!bg-transparent focus:!bg-transparent'
const TOOLBAR_BUTTON_CLASS = '!h-9 !w-9 !px-0 transition-colors'
const TOOLBAR_ACTIVE_CLASS = '!bg-blue-100 text-blue-600 dark:!bg-blue-900 dark:text-blue-400'
const TOOLBAR_IDLE_CLASS =
'text-gray-600 hover:!bg-gray-100 dark:text-gray-400 dark:hover:!bg-gray-700'
const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => { const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
const { translate } = useLocalization() const { translate } = useLocalization()
const [content, setContent] = useState('') const [content, setContent] = useState('')
const [mediaType, setMediaType] = useState<'media' | 'poll' | null>(null) const [mediaType, setMediaType] = useState<'media' | 'poll' | null>(null)
const [mediaItems, setMediaItems] = useState<SocialMediaDto[]>([]) const [mediaItems, setMediaItems] = useState<SocialMediaDto[]>([])
const [location, setLocation] = useState<string | null>(null)
const [pollQuestion, setPollQuestion] = useState('') const [pollQuestion, setPollQuestion] = useState('')
const [pollOptions, setPollOptions] = useState(['', '']) const [pollOptions, setPollOptions] = useState(['', ''])
const [isExpanded, setIsExpanded] = useState(false) const [isExpanded, setIsExpanded] = useState(false)
const [showEmojiPicker, setShowEmojiPicker] = useState(false) const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const [showMediaManager, setShowMediaManager] = useState(false) const [showMediaManager, setShowMediaManager] = useState(false)
const [showLocationPicker, setShowLocationPicker] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null)
const emojiPickerRef = useRef<HTMLDivElement>(null) const emojiContainerRef = useRef<HTMLDivElement>(null)
const { user, tenant } = useStoreState((state) => state.auth) const { user, tenant } = useStoreState((state) => state.auth)
const theme = useStoreState((state) => state.theme) const theme = useStoreState((state) => state.theme)
const handleSubmit = (e: React.FormEvent) => { const filledPollOptions = useMemo(
e.preventDefault() () => pollOptions.map((option) => option.trim()).filter(Boolean),
[pollOptions],
)
const hasMedia = mediaType === 'media' && mediaItems.length > 0
const hasPoll =
mediaType === 'poll' && !!pollQuestion.trim() && filledPollOptions.length >= MIN_POLL_OPTIONS
const canSubmit = !!content.trim() || hasMedia || hasPoll
if (!content.trim() && mediaItems.length === 0 && !mediaType) return const resetForm = () => {
let media = undefined
if (mediaType === 'media' && mediaItems.length > 0) {
media = {
type: 'mixed' as const,
mediaItems,
}
} else if (
mediaType === 'poll' &&
pollQuestion &&
pollOptions.filter((o) => o.trim()).length >= 2
) {
media = {
type: 'poll' as const,
poll: {
question: pollQuestion,
options: pollOptions.filter((o) => o.trim()).map((text) => ({ text })),
},
}
}
onCreatePost({
content,
media,
location: location || undefined,
})
// Reset form
setContent('') setContent('')
setMediaType(null) setMediaType(null)
setMediaItems([]) setMediaItems([])
setLocation(null)
setPollQuestion('') setPollQuestion('')
setPollOptions(['', '']) setPollOptions(['', ''])
setIsExpanded(false) setIsExpanded(false)
setShowEmojiPicker(false) setShowEmojiPicker(false)
} }
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!canSubmit) return
let media: Parameters<CreatePostProps['onCreatePost']>[0]['media']
if (hasMedia) {
media = { type: 'mixed', mediaItems }
} else if (hasPoll) {
media = {
type: 'poll',
poll: {
question: pollQuestion.trim(),
options: filledPollOptions.map((text) => ({ text })),
},
}
}
onCreatePost({ content, media })
resetForm()
}
const handleEmojiClick = (emojiData: EmojiClickData) => { const handleEmojiClick = (emojiData: EmojiClickData) => {
const emoji = emojiData.emoji
const textarea = textareaRef.current const textarea = textareaRef.current
if (!textarea) return if (!textarea) return
const { emoji } = emojiData
const start = textarea.selectionStart const start = textarea.selectionStart
const end = textarea.selectionEnd const end = textarea.selectionEnd
const text = content
const before = text.substring(0, start)
const after = text.substring(end)
setContent(before + emoji + after) setContent((prev) => prev.substring(0, start) + emoji + prev.substring(end))
// Set cursor position after emoji // Emoji eklendikten sonra imleci emojinin sonuna taşı
setTimeout(() => { requestAnimationFrame(() => {
textarea.selectionStart = textarea.selectionEnd = start + emoji.length textarea.selectionStart = textarea.selectionEnd = start + emoji.length
textarea.focus() textarea.focus()
}, 0) })
} }
const addPollOption = () => { const addPollOption = () =>
if (pollOptions.length < 6) { setPollOptions((prev) => (prev.length < MAX_POLL_OPTIONS ? [...prev, ''] : prev))
setPollOptions([...pollOptions, ''])
}
}
const removePollOption = (index: number) => { const removePollOption = (index: number) =>
if (pollOptions.length > 2) { setPollOptions((prev) =>
setPollOptions(pollOptions.filter((_, i) => i !== index)) prev.length > MIN_POLL_OPTIONS ? prev.filter((_, i) => i !== index) : prev,
} )
}
const updatePollOption = (index: number, value: string) => { const updatePollOption = (index: number, value: string) =>
const newOptions = [...pollOptions] setPollOptions((prev) => prev.map((option, i) => (i === index ? value : option)))
newOptions[index] = value
setPollOptions(newOptions)
}
const clearMedia = () => { const clearMedia = () => {
setMediaType(null) setMediaType(null)
@ -135,24 +130,21 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
const removeMediaItem = (id: string | undefined) => { const removeMediaItem = (id: string | undefined) => {
if (!id) return if (!id) return
setMediaItems(mediaItems.filter((m) => m.id !== id)) setMediaItems((prev) => prev.filter((m) => m.id !== id))
} }
// Close emoji picker when clicking outside // Emoji seçici dışına tıklanınca kapat (tetikleyici buton da kapsayıcının içinde)
React.useEffect(() => { useEffect(() => {
if (!showEmojiPicker) return
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (emojiPickerRef.current && !emojiPickerRef.current.contains(event.target as Node)) { if (!emojiContainerRef.current?.contains(event.target as Node)) {
setShowEmojiPicker(false) setShowEmojiPicker(false)
} }
} }
if (showEmojiPicker) {
document.addEventListener('mousedown', handleClickOutside) document.addEventListener('mousedown', handleClickOutside)
} return () => document.removeEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [showEmojiPicker]) }, [showEmojiPicker])
return ( return (
@ -179,7 +171,7 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
{/* Media Preview */} {/* Media Preview */}
<AnimatePresence> <AnimatePresence>
{mediaType === 'media' && mediaItems.length > 0 && ( {hasMedia && (
<motion.div <motion.div
initial={{ opacity: 0, height: 0 }} initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }} animate={{ opacity: 1, height: 'auto' }}
@ -193,12 +185,10 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
</h4> </h4>
<Button <Button
type="button" type="button"
onClick={() => { onClick={clearMedia}
clearMedia()
}}
variant="plain" variant="plain"
shape="none" shape="none"
className="!h-auto !rounded-none !px-0 !py-0 text-sm font-medium text-red-600 hover:!bg-transparent hover:text-red-700 active:!bg-transparent focus:!bg-transparent" className={classNames(LINK_BUTTON_CLASS, 'text-red-600 hover:text-red-700')}
title={translate( title={translate(
'::App.Platform.Intranet.SocialWall.CreatePost.RemoveAllMediaTitle', '::App.Platform.Intranet.SocialWall.CreatePost.RemoveAllMediaTitle',
)} )}
@ -220,10 +210,12 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
<video <video
src={item.urls?.[0]} src={item.urls?.[0]}
className="w-full h-full object-cover rounded-lg" className="w-full h-full object-cover rounded-lg"
muted
preload="metadata"
/> />
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<div className="w-10 h-10 bg-black bg-opacity-50 rounded-full flex items-center justify-center"> <div className="w-10 h-10 bg-black bg-opacity-50 rounded-full flex items-center justify-center">
<div className="w-0 h-0 border-t-8 border-t-transparent border-l-12 border-l-white border-b-8 border-b-transparent ml-1"></div> <div className="w-0 h-0 border-t-8 border-t-transparent border-l-12 border-l-white border-b-8 border-b-transparent ml-1" />
</div> </div>
</div> </div>
</div> </div>
@ -256,46 +248,6 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
</motion.div> </motion.div>
)} )}
{location && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="mb-4"
>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
{translate('::App.Platform.Intranet.SocialWall.CreatePost.Location')}
</h4>
<Button
type="button"
onClick={() => setLocation(null)}
variant="plain"
shape="none"
className="!h-auto !rounded-none !px-0 !py-0 text-sm font-medium text-red-600 hover:!bg-transparent hover:text-red-700 active:!bg-transparent focus:!bg-transparent"
title={translate(
'::App.Platform.Intranet.SocialWall.CreatePost.RemoveLocationTitle',
)}
>
{translate('::Cancel')}
</Button>
</div>
<div className="p-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-gray-50 dark:bg-gray-700">
<div className="flex items-start gap-2">
<FaMapMarkerAlt className="w-5 h-5 text-blue-600 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 dark:text-gray-100 mb-1">
{JSON.parse(location).name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{JSON.parse(location).address}
</p>
</div>
</div>
</div>
</motion.div>
)}
{mediaType === 'poll' && ( {mediaType === 'poll' && (
<motion.div <motion.div
initial={{ opacity: 0, height: 0 }} initial={{ opacity: 0, height: 0 }}
@ -309,12 +261,10 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
</h4> </h4>
<Button <Button
type="button" type="button"
onClick={() => { onClick={clearMedia}
clearMedia()
}}
variant="plain" variant="plain"
shape="none" shape="none"
className="!h-auto !rounded-none !px-0 !py-0 text-sm font-medium text-red-600 hover:!bg-transparent hover:text-red-700 active:!bg-transparent focus:!bg-transparent" className={classNames(LINK_BUTTON_CLASS, 'text-red-600 hover:text-red-700')}
title={translate('::App.Platform.Intranet.SocialWall.CreatePost.RemovePollTitle')} title={translate('::App.Platform.Intranet.SocialWall.CreatePost.RemovePollTitle')}
> >
{translate('::Cancel')} {translate('::Cancel')}
@ -331,6 +281,7 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
/> />
<div className="space-y-2"> <div className="space-y-2">
{pollOptions.map((option, index) => ( {pollOptions.map((option, index) => (
// Seçenekler stabil bir kimliğe sahip değil; sıra indeksi anahtar olarak kullanılıyor
<div key={index} className="flex gap-2"> <div key={index} className="flex gap-2">
<input <input
type="text" type="text"
@ -341,7 +292,7 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
)} )}
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/> />
{pollOptions.length > 2 && ( {pollOptions.length > MIN_POLL_OPTIONS && (
<Button <Button
type="button" type="button"
onClick={() => removePollOption(index)} onClick={() => removePollOption(index)}
@ -354,13 +305,16 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
</div> </div>
))} ))}
</div> </div>
{pollOptions.length < 6 && ( {pollOptions.length < MAX_POLL_OPTIONS && (
<Button <Button
type="button" type="button"
onClick={addPollOption} onClick={addPollOption}
variant="plain" variant="plain"
shape="none" shape="none"
className="mt-2 !h-auto !rounded-none !px-0 !py-0 text-sm font-medium text-blue-600 hover:!bg-transparent hover:text-blue-700 active:!bg-transparent focus:!bg-transparent" className={classNames(
LINK_BUTTON_CLASS,
'mt-2 text-blue-600 hover:text-blue-700',
)}
> >
+ {translate('::App.Platform.Intranet.SocialWall.CreatePost.AddOption')} + {translate('::App.Platform.Intranet.SocialWall.CreatePost.AddOption')}
</Button> </Button>
@ -382,23 +336,19 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
<Button <Button
type="button" type="button"
onClick={() => { onClick={() => {
if (mediaType === 'media' && mediaItems.length > 0) { // Farklı bir tip seçiliyse önce temizle, sonra medya modunu aç
// Eğer zaten medya varsa, yöneticiyi aç if (mediaType !== 'media') {
setShowMediaManager(true)
} else {
// Başka bir tip seçiliyse temizle ve medya modunu aç
clearMedia() clearMedia()
setMediaType('media') setMediaType('media')
setShowMediaManager(true)
} }
setShowMediaManager(true)
}} }}
variant="plain" variant="plain"
shape="circle" shape="circle"
className={classNames( className={classNames(
'relative !h-9 !w-9 !px-0 overflow-visible transition-colors', TOOLBAR_BUTTON_CLASS,
mediaType === 'media' 'relative overflow-visible',
? '!bg-blue-100 text-blue-600 dark:!bg-blue-900 dark:text-blue-400' mediaType === 'media' ? TOOLBAR_ACTIVE_CLASS : TOOLBAR_IDLE_CLASS,
: 'text-gray-600 hover:!bg-gray-100 dark:text-gray-400 dark:hover:!bg-gray-700',
)} )}
title={ title={
mediaType === 'media' mediaType === 'media'
@ -409,7 +359,7 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
<span className="absolute inset-0 flex items-center justify-center"> <span className="absolute inset-0 flex items-center justify-center">
<FaImages className="h-5 w-5" /> <FaImages className="h-5 w-5" />
</span> </span>
{mediaType === 'media' && mediaItems.length > 0 && ( {hasMedia && (
<span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-600 text-white text-xs rounded-full flex items-center justify-center"> <span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-600 text-white text-xs rounded-full flex items-center justify-center">
{mediaItems.length} {mediaItems.length}
</span> </span>
@ -418,20 +368,19 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
<Button <Button
type="button" type="button"
onClick={() => { onClick={() => {
// Başka bir tip seçiliyse temizle if (mediaType === 'poll') {
if (mediaType !== 'poll') {
clearMedia() clearMedia()
return
} }
setMediaType(mediaType === 'poll' ? null : 'poll') clearMedia()
setMediaType('poll')
}} }}
variant="plain" variant="plain"
shape="circle" shape="circle"
icon={<FaChartBar className="h-5 w-5" />} icon={<FaChartBar className="h-5 w-5" />}
className={classNames( className={classNames(
'!h-9 !w-9 !px-0 transition-colors', TOOLBAR_BUTTON_CLASS,
mediaType === 'poll' mediaType === 'poll' ? TOOLBAR_ACTIVE_CLASS : TOOLBAR_IDLE_CLASS,
? '!bg-blue-100 text-blue-600 dark:!bg-blue-900 dark:text-blue-400'
: 'text-gray-600 hover:!bg-gray-100 dark:text-gray-400 dark:hover:!bg-gray-700',
)} )}
title={ title={
mediaType === 'poll' mediaType === 'poll'
@ -439,41 +388,26 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
: translate('::App.Platform.Intranet.SocialWall.CreatePost.AddPollTitle') : translate('::App.Platform.Intranet.SocialWall.CreatePost.AddPollTitle')
} }
/> />
{/* Emoji Picker */}
<div ref={emojiContainerRef} className="relative">
<Button <Button
type="button" type="button"
onClick={() => setShowEmojiPicker(!showEmojiPicker)} onClick={() => setShowEmojiPicker((prev) => !prev)}
variant="plain" variant="plain"
shape="circle" shape="circle"
icon={<FaSmile className="h-5 w-5" />} icon={<FaSmile className="h-5 w-5" />}
className="!h-9 !w-9 !px-0 text-gray-600 transition-colors hover:!bg-gray-100 dark:text-gray-400 dark:hover:!bg-gray-700" className={classNames(TOOLBAR_BUTTON_CLASS, TOOLBAR_IDLE_CLASS)}
title={translate('::App.Platform.Intranet.SocialWall.CreatePost.AddEmojiTitle')} title={translate('::App.Platform.Intranet.SocialWall.CreatePost.AddEmojiTitle')}
aria-expanded={showEmojiPicker}
/> />
<Button
type="button"
onClick={() => setShowLocationPicker(true)}
variant="plain"
shape="circle"
icon={<FaMapMarkerAlt className="h-5 w-5" />}
className={classNames(
'!h-9 !w-9 !px-0 transition-colors',
location
? '!bg-blue-100 text-blue-600 dark:!bg-blue-900 dark:text-blue-400'
: 'text-gray-600 hover:!bg-gray-100 dark:text-gray-400 dark:hover:!bg-gray-700',
)}
title={
location
? translate('::App.Platform.Intranet.SocialWall.CreatePost.EditLocationTitle')
: translate('::App.Platform.Intranet.SocialWall.CreatePost.AddLocationTitle')
}
/>
{/* Emoji Picker */}
{showEmojiPicker && ( {showEmojiPicker && (
<div <div className="absolute bottom-full left-0 z-50 mb-2 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-2">
ref={emojiPickerRef} <React.Suspense
className="absolute bottom-6 left-0 z-50 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-2" fallback={
<div className="h-[350px] w-[350px] max-w-[calc(100vw-32px)]" />
}
> >
<React.Suspense fallback={<div className="h-[350px] w-[350px] max-w-[calc(100vw-32px)]" />}>
<EmojiPicker <EmojiPicker
searchDisabled searchDisabled
theme={(theme.mode === 'dark' ? 'dark' : 'light') as Theme} theme={(theme.mode === 'dark' ? 'dark' : 'light') as Theme}
@ -485,12 +419,8 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
</div> </div>
)} )}
</div> </div>
<Button </div>
size="sm" <Button size="sm" type="submit" disabled={!canSubmit} variant="solid">
type="submit"
disabled={!content.trim() && mediaItems.length === 0 && !mediaType}
variant="solid"
>
{translate('::App.Platform.Intranet.SocialWall.CreatePost.Submit')} {translate('::App.Platform.Intranet.SocialWall.CreatePost.Submit')}
</Button> </Button>
</motion.div> </motion.div>
@ -508,13 +438,6 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
/> />
)} )}
</AnimatePresence> </AnimatePresence>
{/* Location Picker Modal */}
<AnimatePresence>
{showLocationPicker && (
<LocationPicker onSelect={setLocation} onClose={() => setShowLocationPicker(false)} />
)}
</AnimatePresence>
</div> </div>
) )
} }

View file

@ -1,120 +0,0 @@
import React from 'react'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { FaExternalLinkAlt, FaMapMarkerAlt } from 'react-icons/fa'
import Button from '@/components/ui/Button'
interface LocationData {
id: string
name: string
address: string
lat: number
lng: number
placeId?: string
}
interface LocationMapProps {
location: string // JSON string
className?: string
showDirections?: boolean
}
const LocationMap: React.FC<LocationMapProps> = ({
location,
className = '',
showDirections = true,
}) => {
const locationData: LocationData = JSON.parse(location)
const handleOpenGoogleMaps = () => {
const url = `https://www.google.com/maps/search/?api=1&query=${locationData.lat},${locationData.lng}&query_place_id=${locationData.placeId || ''}`
window.open(url, '_blank')
}
// Google Maps Static API URL (gerçek uygulamada API key eklenecek)
const getMapImageUrl = () => {
const { lat, lng } = locationData
const zoom = 15
const size = '600x300'
const marker = `color:red|${lat},${lng}`
// Production'da gerçek API key kullanılacak
// const apiKey = 'YOUR_GOOGLE_MAPS_API_KEY'
// return `https://maps.googleapis.com/maps/api/staticmap?center=${lat},${lng}&zoom=${zoom}&size=${size}&markers=${marker}&key=${apiKey}`
// Demo için OpenStreetMap kullanıyoruz
return `https://www.openstreetmap.org/export/embed.html?bbox=${lng - 0.01},${lat - 0.01},${lng + 0.01},${lat + 0.01}&layer=mapnik&marker=${lat},${lng}`
}
const { translate } = useLocalization()
return (
<div
className={`relative rounded-lg overflow-hidden bg-gray-200 dark:bg-gray-700 ${className}`}
>
{/* Map Container */}
<div className="relative w-full h-64 group">
{/* OpenStreetMap iframe for demo */}
<iframe
title={`Map of ${locationData.name}`}
src={getMapImageUrl()}
className="w-full h-full border-0"
allowFullScreen
loading="lazy"
/>
{/* Overlay with location info */}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
{/* Location Info */}
<div className="absolute bottom-0 left-0 right-0 p-4 text-white pointer-events-none">
<div className="flex items-start gap-2">
<FaMapMarkerAlt className="w-5 h-5 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<h3 className="font-bold text-lg mb-1 drop-shadow-lg">{locationData.name}</h3>
<p className="text-sm text-white/90 drop-shadow-md line-clamp-2">
{locationData.address}
</p>
</div>
</div>
</div>
{/* Click to open overlay - invisible but clickable */}
<Button
type="button"
onClick={handleOpenGoogleMaps}
variant="plain"
shape="none"
className="absolute inset-0 !h-full !w-full !rounded-none !p-0 cursor-pointer hover:!bg-transparent active:!bg-transparent focus:!bg-transparent"
aria-label={translate('::App.Platform.Intranet.SocialWall.LocationMap.OpenInGoogleMaps')}
>
<span className="sr-only">
{translate('::App.Platform.Intranet.SocialWall.LocationMap.OpenInGoogleMaps')}
</span>
</Button>
{/* Hover Effect */}
<div className="absolute inset-0 bg-blue-600/0 group-hover:bg-blue-600/10 transition-colors duration-200" />
</div>
{/* Directions Button */}
{showDirections && (
<div className="p-3 bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700">
<Button
type="button"
onClick={handleOpenGoogleMaps}
variant="solid"
icon={<FaExternalLinkAlt className="h-5 w-5" />}
>
<span>
{translate('::App.Platform.Intranet.SocialWall.LocationMap.OpenInGoogleMaps')}
</span>
</Button>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2 text-center">
{translate('::App.Platform.Intranet.SocialWall.LocationMap.ClickForDirections')}
</p>
</div>
)}
</div>
)
}
export default LocationMap

View file

@ -1,430 +0,0 @@
import React, { useState, useEffect, useRef } from 'react'
import { useLocalization } from '@/utils/hooks/useLocalization'
import { motion } from 'framer-motion'
import { FaTimes, FaSearch, FaMapMarkerAlt } from 'react-icons/fa'
import classNames from 'classnames'
import Button from '@/components/ui/Button'
interface LocationPickerProps {
onSelect: (location: string) => void
onClose: () => void
}
interface LocationData {
id: string
name: string
address: string
lat: number
lng: number
placeId?: string
}
// Google Maps API key - .env dosyasından alınmalı
const GOOGLE_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || ''
declare global {
interface Window {
google: any
initGoogleMaps?: () => void
}
}
const LocationPicker: React.FC<LocationPickerProps> = ({ onSelect, onClose }) => {
const { translate } = useLocalization()
const [searchQuery, setSearchQuery] = useState('')
const [locations, setLocations] = useState<LocationData[]>([])
const [selectedLocation, setSelectedLocation] = useState<LocationData | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isGoogleLoaded, setIsGoogleLoaded] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null)
const autocompleteServiceRef = useRef<any>(null)
const placesServiceRef = useRef<any>(null)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout>>()
const scriptLoadedRef = useRef(false)
// Google Maps SDK'yı yükle
useEffect(() => {
if (scriptLoadedRef.current) return
const loadGoogleMaps = () => {
if (window.google && window.google.maps && window.google.maps.places) {
setIsGoogleLoaded(true)
autocompleteServiceRef.current = new window.google.maps.places.AutocompleteService()
const mapDiv = document.createElement('div')
const map = new window.google.maps.Map(mapDiv)
placesServiceRef.current = new window.google.maps.places.PlacesService(map)
return
}
if (!GOOGLE_API_KEY) {
setError(translate('::App.Platform.Intranet.SocialWall.LocationPicker.ApiKeyError'))
return
}
// Script zaten yüklendiyse sadece bekle
const existingScript = document.querySelector('script[src*="maps.googleapis.com"]')
if (existingScript) {
const checkInterval = setInterval(() => {
if (window.google && window.google.maps && window.google.maps.places) {
clearInterval(checkInterval)
setIsGoogleLoaded(true)
autocompleteServiceRef.current = new window.google.maps.places.AutocompleteService()
const mapDiv = document.createElement('div')
const map = new window.google.maps.Map(mapDiv)
placesServiceRef.current = new window.google.maps.places.PlacesService(map)
}
}, 100)
return
}
// Yeni script ekle
const script = document.createElement('script')
script.src = `https://maps.googleapis.com/maps/api/js?key=${GOOGLE_API_KEY}&libraries=places&language=tr`
script.async = true
script.defer = true
script.onload = () => {
if (window.google && window.google.maps && window.google.maps.places) {
setIsGoogleLoaded(true)
autocompleteServiceRef.current = new window.google.maps.places.AutocompleteService()
const mapDiv = document.createElement('div')
const map = new window.google.maps.Map(mapDiv)
placesServiceRef.current = new window.google.maps.places.PlacesService(map)
}
}
script.onerror = () => {
setError(translate('::App.Platform.Intranet.SocialWall.LocationPicker.GoogleMapsLoadError'))
}
document.head.appendChild(script)
scriptLoadedRef.current = true
}
loadGoogleMaps()
}, [])
useEffect(() => {
searchInputRef.current?.focus()
}, [])
const getGooglePlacesErrorMessage = (status: any) => {
switch (status) {
case window.google.maps.places.PlacesServiceStatus.ZERO_RESULTS:
return translate('::App.Platform.Intranet.SocialWall.LocationPicker.NoResults')
case window.google.maps.places.PlacesServiceStatus.OVER_QUERY_LIMIT:
return translate('::App.Platform.Intranet.SocialWall.LocationPicker.OverQueryLimit')
case window.google.maps.places.PlacesServiceStatus.REQUEST_DENIED:
return translate('::App.Platform.Intranet.SocialWall.LocationPicker.RequestDenied')
case window.google.maps.places.PlacesServiceStatus.INVALID_REQUEST:
return translate('::App.Platform.Intranet.SocialWall.LocationPicker.InvalidRequest')
case window.google.maps.places.PlacesServiceStatus.UNKNOWN_ERROR:
return translate('::App.Platform.Intranet.SocialWall.LocationPicker.UnknownError')
default:
return translate('::App.Platform.Intranet.SocialWall.LocationPicker.SearchFailed')
}
}
// Google Places Autocomplete ile konum arama
useEffect(() => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
if (searchQuery.trim() === '') {
setLocations([])
setError(null)
return
}
if (!isGoogleLoaded) {
return
}
debounceTimerRef.current = setTimeout(async () => {
setIsLoading(true)
setError(null)
try {
// Google Places Autocomplete Service kullan (CORS yok)
autocompleteServiceRef.current.getPlacePredictions(
{
input: searchQuery,
componentRestrictions: { country: 'tr' },
language: 'tr',
},
async (predictions: any, status: any) => {
if (status === window.google.maps.places.PlacesServiceStatus.ZERO_RESULTS) {
setLocations([])
setError(getGooglePlacesErrorMessage(status))
setIsLoading(false)
return
}
if (status !== window.google.maps.places.PlacesServiceStatus.OK) {
setLocations([])
setError(getGooglePlacesErrorMessage(status))
setIsLoading(false)
return
}
if (!predictions || predictions.length === 0) {
setLocations([])
setError(translate('::App.Platform.Intranet.SocialWall.LocationPicker.NoResults'))
setIsLoading(false)
return
}
setError(null)
const detailedLocations: LocationData[] = []
let completed = 0
predictions.forEach((prediction: any) => {
placesServiceRef.current.getDetails(
{
placeId: prediction.place_id,
fields: ['name', 'formatted_address', 'geometry', 'place_id'],
},
(place: any, placeStatus: any) => {
completed++
if (placeStatus === window.google.maps.places.PlacesServiceStatus.OK && place) {
detailedLocations.push({
id: place.place_id,
name: place.name,
address: place.formatted_address,
lat: place.geometry.location.lat(),
lng: place.geometry.location.lng(),
placeId: place.place_id,
})
}
if (completed === predictions.length) {
if (detailedLocations.length === 0) {
setError('Konum detayları alınamadı. API yetkilerini kontrol ediniz.')
} else {
setError(null)
}
setLocations(detailedLocations)
setIsLoading(false)
}
},
)
})
},
)
} catch (err) {
console.error('Location search error:', err)
setError(translate('::App.Platform.Intranet.SocialWall.LocationPicker.SearchError'))
setIsLoading(false)
}
}, 500) // 500ms debounce
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current)
}
}
}, [searchQuery, isGoogleLoaded])
const handleSelect = (location: LocationData) => {
setSelectedLocation(location)
}
const handleConfirm = () => {
if (selectedLocation) {
onSelect(JSON.stringify(selectedLocation))
onClose()
}
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col"
>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
{translate('::App.Platform.Intranet.SocialWall.LocationPicker.AddLocation')}
</h2>
<Button
size="sm"
onClick={onClose}
variant="plain"
icon={<FaTimes className="h-5 w-5 text-gray-500 dark:text-gray-400" />}
/>
</div>
{/* Search */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700">
<div className="relative">
<FaSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={translate(
'::App.Platform.Intranet.SocialWall.LocationPicker.SearchPlaceholder',
)}
disabled={!isGoogleLoaded}
className="w-full pl-10 pr-2 py-1 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 dark:disabled:bg-gray-600 disabled:cursor-not-allowed"
/>
</div>
{!isGoogleLoaded && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
{translate('::App.Platform.Intranet.SocialWall.LocationPicker.LoadingGoogleMaps')}
</p>
)}
</div>
{/* Location List */}
<div className="flex-1 overflow-y-auto p-4">
{!isGoogleLoaded ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-500 dark:text-gray-400">
{translate('::App.Platform.Intranet.SocialWall.LocationPicker.LoadingGoogleMaps')}
</p>
</div>
) : isLoading ? (
<div className="text-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-500 dark:text-gray-400">
{translate('::App.Platform.Intranet.SocialWall.LocationPicker.SearchingLocations')}
</p>
</div>
) : error ? (
<div className="text-center py-12">
<FaMapMarkerAlt className="w-16 h-16 mx-auto mb-4 text-red-400" />
<p className="text-red-500 dark:text-red-400">{error}</p>
</div>
) : searchQuery.trim() === '' ? (
<div className="text-center py-12">
<FaSearch className="w-16 h-16 mx-auto mb-4 text-gray-400" />
<p className="text-gray-500 dark:text-gray-400">
{translate('::App.Platform.Intranet.SocialWall.LocationPicker.TypeToSearch')}
</p>
<p className="text-sm text-gray-400 dark:text-gray-500 mt-2">
{translate('::App.Platform.Intranet.SocialWall.LocationPicker.Example')}
</p>
</div>
) : locations.length === 0 ? (
<div className="text-center py-12">
<FaMapMarkerAlt className="w-16 h-16 mx-auto mb-4 text-gray-400" />
<p className="text-gray-500 dark:text-gray-400">
{translate('::App.Platform.Intranet.SocialWall.LocationPicker.NotFound')}
</p>
</div>
) : (
<div className="space-y-2">
{locations.map((location) => (
<Button
size="sm"
key={location.id}
onClick={() => handleSelect(location)}
variant="plain"
>
<div className="flex items-start gap-3">
<div className="mt-1">
<FaMapMarkerAlt
className={classNames(
'w-5 h-5',
selectedLocation?.id === location.id ? 'text-blue-600' : 'text-gray-400',
)}
/>
</div>
<div className="flex-1 min-w-0">
<h3
className={classNames(
'font-semibold mb-1',
selectedLocation?.id === location.id
? 'text-blue-600 dark:text-blue-400'
: 'text-gray-900 dark:text-gray-100',
)}
>
{location.name}
</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
{location.address}
</p>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
{location.lat.toFixed(4)}, {location.lng.toFixed(4)}
</p>
</div>
{selectedLocation?.id === location.id && (
<div className="mt-1">
<div className="w-5 h-5 bg-blue-600 rounded-full flex items-center justify-center">
<svg
className="w-3 h-3 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
</div>
)}
</div>
</Button>
))}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<div className="text-sm text-gray-600 dark:text-gray-400">
{selectedLocation ? (
<span className="flex items-center gap-2">
<FaMapMarkerAlt className="w-4 h-4 text-blue-600 dark:text-blue-400" />
<span className="font-medium text-gray-900 dark:text-gray-100">
{selectedLocation.name}
</span>
</span>
) : (
<span>
{translate('::App.Platform.Intranet.SocialWall.LocationPicker.SelectLocation')}
</span>
)}
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={onClose}
variant="plain"
>
{translate('::Cancel')}
</Button>
<Button
size="sm"
onClick={handleConfirm}
disabled={!selectedLocation}
variant="solid"
>
{translate('::ListForms.Wizard.Add')}
</Button>
</div>
</div>
</motion.div>
</div>
)
}
export default LocationPicker

View file

@ -12,11 +12,32 @@ interface MediaManagerProps {
onClose: () => void onClose: () => void
} }
type MediaKind = 'image' | 'video'
const readFileAsDataUrl = (file: File) =>
new Promise<string | null>((resolve) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => resolve(null)
reader.readAsDataURL(file)
})
const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose }) => { const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose }) => {
const { translate } = useLocalization() const { translate } = useLocalization()
const [activeTab, setActiveTab] = useState<'upload' | 'url'>('upload') const [activeTab, setActiveTab] = useState<'upload' | 'url'>('upload')
const [urlInput, setUrlInput] = useState('') const [urlInput, setUrlInput] = useState('')
const [mediaType, setMediaType] = useState<'image' | 'video'>('image') const [mediaType, setMediaType] = useState<MediaKind>('image')
// Backend tek bir medya kaydı tuttuğu için bir gönderide resim ve video
// karıştırılamaz; ilk eklenen öğe gönderinin tipini sabitler.
const lockedType = (media[0]?.type as MediaKind | undefined) ?? null
const effectiveType: MediaKind = lockedType ?? mediaType
const acceptedFiles = lockedType
? lockedType === 'video'
? 'video/*'
: 'image/*'
: 'image/*,video/*'
const tabClassName = (tab: 'upload' | 'url') => const tabClassName = (tab: 'upload' | 'url') =>
classNames( classNames(
'!h-auto !items-center gap-2 !rounded-none border-b-2 !bg-transparent !px-4 !py-3 font-medium transition-colors hover:!bg-gray-50 active:!bg-transparent focus:!bg-transparent dark:hover:!bg-gray-700/50', '!h-auto !items-center gap-2 !rounded-none border-b-2 !bg-transparent !px-4 !py-3 font-medium transition-colors hover:!bg-gray-50 active:!bg-transparent focus:!bg-transparent dark:hover:!bg-gray-700/50',
@ -25,43 +46,45 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
: '!border-transparent text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100', : '!border-transparent text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100',
) )
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const typeButtonClassName = (kind: MediaKind) =>
const files = e.target.files classNames(
if (!files) return 'flex-1 !h-auto !rounded-lg !px-4 !py-2 font-medium transition-colors',
effectiveType === kind
? '!bg-blue-600 !text-white'
: 'bg-gray-100 text-gray-700 hover:!bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:!bg-gray-600',
)
const fileArray = Array.from(files) const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const readers = fileArray.map( const input = e.target
(file) => const files = Array.from(input.files ?? [])
new Promise<SocialMediaDto>((resolve) => { input.value = ''
const reader = new FileReader() if (files.length === 0) return
reader.onload = () => {
resolve({ const results = await Promise.all(
id: Math.random().toString(36).substr(2, 9), files.map(async (file): Promise<SocialMediaDto | null> => {
type: file.type.startsWith('video/') ? 'video' : 'image', const kind: MediaKind = file.type.startsWith('video/') ? 'video' : 'image'
urls: [reader.result as string], if (lockedType && kind !== lockedType) return null
})
} const dataUrl = await readFileAsDataUrl(file)
reader.readAsDataURL(file) if (!dataUrl) return null
return { id: crypto.randomUUID(), type: kind, urls: [dataUrl] }
}), }),
) )
Promise.all(readers).then((newMedia) => { const added = results.filter((item): item is SocialMediaDto => item !== null)
onChange([...media, ...newMedia]) if (added.length === 0) return
})
e.target.value = '' // İlk seçim gönderinin tipini belirler; karışık seçimlerde ilk dosyanın tipi kazanır
const resolvedType = lockedType ?? (added[0].type as MediaKind)
onChange([...media, ...added.filter((item) => item.type === resolvedType)])
} }
const handleUrlAdd = () => { const handleUrlAdd = () => {
if (!urlInput.trim()) return const url = urlInput.trim()
if (!url) return
const newMedia: SocialMediaDto = { onChange([...media, { id: crypto.randomUUID(), type: effectiveType, urls: [url] }])
id: Math.random().toString(36).substr(2, 9),
type: mediaType,
urls: [urlInput],
}
onChange([...media, newMedia])
setUrlInput('') setUrlInput('')
} }
@ -84,6 +107,7 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
{translate('::App.Platform.Intranet.SocialWall.MediaManager.AddMedia')} {translate('::App.Platform.Intranet.SocialWall.MediaManager.AddMedia')}
</h2> </h2>
<Button <Button
type="button"
onClick={onClose} onClick={onClose}
variant="plain" variant="plain"
shape="circle" shape="circle"
@ -95,6 +119,7 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
{/* Tabs */} {/* Tabs */}
<div className="flex border-b border-gray-200 dark:border-gray-700 px-2 py-2"> <div className="flex border-b border-gray-200 dark:border-gray-700 px-2 py-2">
<Button <Button
type="button"
onClick={() => setActiveTab('upload')} onClick={() => setActiveTab('upload')}
variant="plain" variant="plain"
shape="none" shape="none"
@ -106,6 +131,7 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
</span> </span>
</Button> </Button>
<Button <Button
type="button"
onClick={() => setActiveTab('url')} onClick={() => setActiveTab('url')}
variant="plain" variant="plain"
shape="none" shape="none"
@ -119,7 +145,6 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
{/* Content */} {/* Content */}
<div className="p-4 overflow-y-auto max-h-[calc(90vh-240px)]"> <div className="p-4 overflow-y-auto max-h-[calc(90vh-240px)]">
{activeTab === 'upload' ? ( {activeTab === 'upload' ? (
<div>
<label className="block"> <label className="block">
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-8 text-center hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors cursor-pointer"> <div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg p-8 text-center hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors cursor-pointer">
<FaUpload className="w-12 h-12 mx-auto mb-4 text-gray-400" /> <FaUpload className="w-12 h-12 mx-auto mb-4 text-gray-400" />
@ -127,49 +152,41 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
{translate('::App.Platform.Intranet.SocialWall.MediaManager.ClickToSelectFile')} {translate('::App.Platform.Intranet.SocialWall.MediaManager.ClickToSelectFile')}
</p> </p>
<p className="text-sm text-gray-500 dark:text-gray-400"> <p className="text-sm text-gray-500 dark:text-gray-400">
{translate( {translate('::App.Platform.Intranet.SocialWall.MediaManager.ImageOrVideoFormats')}
'::App.Platform.Intranet.SocialWall.MediaManager.ImageOrVideoFormats',
)}
</p> </p>
</div> </div>
<input <input
type="file" type="file"
accept="image/*,video/*" accept={acceptedFiles}
multiple multiple
onChange={handleFileSelect} onChange={handleFileSelect}
className="hidden" className="hidden"
/> />
</label> </label>
</div>
) : ( ) : (
<div> <div>
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <span className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{translate('::App.Platform.Intranet.SocialWall.MediaManager.MediaType')} {translate('::App.Platform.Intranet.SocialWall.MediaManager.MediaType')}
</label> </span>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
type="button"
onClick={() => setMediaType('image')} onClick={() => setMediaType('image')}
variant="solid" disabled={lockedType === 'video'}
className={classNames( variant="plain"
'flex-1 !h-auto !rounded-lg !px-4 !py-2 font-medium transition-colors', shape="none"
mediaType === 'image' className={typeButtonClassName('image')}
? '!bg-blue-600 !text-white'
: 'bg-gray-100 text-gray-700 hover:!bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:!bg-gray-600',
)}
> >
{translate('::App.Platform.Intranet.SocialWall.MediaManager.Image')} {translate('::App.Platform.Intranet.SocialWall.MediaManager.Image')}
</Button> </Button>
<Button <Button
type="button"
onClick={() => setMediaType('video')} onClick={() => setMediaType('video')}
disabled={lockedType === 'image'}
variant="plain" variant="plain"
shape="none" shape="none"
className={classNames( className={typeButtonClassName('video')}
'flex-1 !h-auto !rounded-lg !px-4 !py-2 font-medium transition-colors',
mediaType === 'video'
? '!bg-blue-600 !text-white'
: 'bg-gray-100 text-gray-700 hover:!bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:!bg-gray-600',
)}
> >
{translate('::App.Platform.Intranet.SocialWall.MediaManager.Video')} {translate('::App.Platform.Intranet.SocialWall.MediaManager.Video')}
</Button> </Button>
@ -180,15 +197,21 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
type="url" type="url"
value={urlInput} value={urlInput}
onChange={(e) => setUrlInput(e.target.value)} onChange={(e) => setUrlInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && handleUrlAdd()} onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleUrlAdd()
}
}}
placeholder={ placeholder={
mediaType === 'image' effectiveType === 'image'
? translate('::App.Platform.Intranet.SocialWall.MediaManager.EnterImageUrl') ? translate('::App.Platform.Intranet.SocialWall.MediaManager.EnterImageUrl')
: translate('::App.Platform.Intranet.SocialWall.MediaManager.EnterVideoUrl') : translate('::App.Platform.Intranet.SocialWall.MediaManager.EnterVideoUrl')
} }
className="flex-1 px-2 py-1 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" className="flex-1 px-2 py-1 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500"
/> />
<Button <Button
type="button"
size="sm" size="sm"
onClick={handleUrlAdd} onClick={handleUrlAdd}
disabled={!urlInput.trim()} disabled={!urlInput.trim()}
@ -217,19 +240,22 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
className="w-full h-24 object-cover rounded-lg" className="w-full h-24 object-cover rounded-lg"
/> />
) : ( ) : (
<div className="w-full h-24 bg-gray-900 rounded-lg flex items-center justify-center"> <div className="relative w-full h-24 bg-gray-900 rounded-lg">
<video <video
src={item.urls?.[0]} src={item.urls?.[0]}
className="w-full h-full object-cover rounded-lg" className="w-full h-full object-cover rounded-lg"
muted
preload="metadata"
/> />
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<div className="w-10 h-10 bg-black bg-opacity-50 rounded-full flex items-center justify-center"> <div className="w-10 h-10 bg-black bg-opacity-50 rounded-full flex items-center justify-center">
<div className="w-0 h-0 border-t-8 border-t-transparent border-l-12 border-l-white border-b-8 border-b-transparent ml-1"></div> <div className="w-0 h-0 border-t-8 border-t-transparent border-l-12 border-l-white border-b-8 border-b-transparent ml-1" />
</div> </div>
</div> </div>
</div> </div>
)} )}
<Button <Button
type="button"
onClick={() => removeMedia(item.id)} onClick={() => removeMedia(item.id)}
variant="solid" variant="solid"
shape="circle" shape="circle"
@ -250,14 +276,11 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
<Button <Button type="button" size="sm" onClick={onClose} variant="plain">
size="sm"
onClick={onClose}
variant="plain"
>
{translate('::Cancel')} {translate('::Cancel')}
</Button> </Button>
<Button <Button
type="button"
size="sm" size="sm"
onClick={onClose} onClick={onClose}
disabled={media.length === 0} disabled={media.length === 0}

View file

@ -1,11 +1,18 @@
import React, { useState, useRef, useEffect } from 'react' import React, { useState, useRef, useEffect, useMemo } from 'react'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { motion, AnimatePresence } from 'framer-motion' import { motion, AnimatePresence } from 'framer-motion'
import classNames from 'classnames' import classNames from 'classnames'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime' import relativeTime from 'dayjs/plugin/relativeTime'
import 'dayjs/locale/tr' import DOMPurify from 'dompurify'
import { FaHeart, FaRegHeart, FaRegCommentAlt, FaTrash, FaPaperPlane } from 'react-icons/fa' import {
FaHeart,
FaRegHeart,
FaRegCommentAlt,
FaTrash,
FaPaperPlane,
FaExpand,
} from 'react-icons/fa'
import MediaLightbox from './MediaLightbox' import MediaLightbox from './MediaLightbox'
import UserProfileCard from './UserProfileCard' import UserProfileCard from './UserProfileCard'
import { SocialPostDto } from '@/proxy/intranet/models' import { SocialPostDto } from '@/proxy/intranet/models'
@ -13,8 +20,13 @@ import { useStoreState } from '@/store/store'
import { AVATAR_URL } from '@/constants/app.constant' import { AVATAR_URL } from '@/constants/app.constant'
import { Avatar, Button } from '@/components/ui' import { Avatar, Button } from '@/components/ui'
// Aktif dil `useLocale` tarafından global olarak ayarlanır; burada sabitlenmez.
dayjs.extend(relativeTime) dayjs.extend(relativeTime)
dayjs.locale('tr')
const MAX_VISIBLE_IMAGES = 4
const ACTION_BUTTON_CLASS =
'!h-auto !items-center gap-2 !rounded-none !border-0 !bg-transparent !px-0 !py-0 text-gray-600 transition-colors hover:!bg-transparent active:!bg-transparent focus:!bg-transparent dark:text-gray-400'
interface PostItemProps { interface PostItemProps {
post: SocialPostDto post: SocialPostDto
@ -24,6 +36,12 @@ interface PostItemProps {
onVote: (postId: string, optionId: string) => void onVote: (postId: string, optionId: string) => void
} }
const imageGridClass = (count: number) => {
if (count === 1) return { grid: '', cell: 'aspect-video' }
if (count === 3) return { grid: 'grid gap-1 grid-cols-3', cell: 'aspect-square' }
return { grid: 'grid gap-1 grid-cols-2', cell: 'aspect-square' }
}
const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete, onVote }) => { const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete, onVote }) => {
const { translate } = useLocalization() const { translate } = useLocalization()
const [showComments, setShowComments] = useState(false) const [showComments, setShowComments] = useState(false)
@ -35,6 +53,7 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
const [hoveredCommentAuthor, setHoveredCommentAuthor] = useState<string | null>(null) const [hoveredCommentAuthor, setHoveredCommentAuthor] = useState<string | null>(null)
const videoRef = useRef<HTMLVideoElement>(null) const videoRef = useRef<HTMLVideoElement>(null)
const { user } = useStoreState((state) => state.auth) const { user } = useStoreState((state) => state.auth)
const postUser = post.user ?? user ?? {} const postUser = post.user ?? user ?? {}
const postComments = post.comments ?? [] const postComments = post.comments ?? []
const postLikeCount = post.likeCount ?? 0 const postLikeCount = post.likeCount ?? 0
@ -44,91 +63,68 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
postUser.fullName || [postUser.name, postUser.surname].filter(Boolean).join(' ') || '-' postUser.fullName || [postUser.name, postUser.surname].filter(Boolean).join(' ') || '-'
const postUserTitle = postUser.jobPositions?.[0]?.name || '' const postUserTitle = postUser.jobPositions?.[0]?.name || ''
// Intersection Observer for video autoplay/pause const mediaType = post.media?.type
const sanitizedContent = useMemo(
() => DOMPurify.sanitize(post.content || ''),
[post.content],
)
// Video ekranda görünürken oynat, çıkınca durdur
useEffect(() => { useEffect(() => {
const video = videoRef.current const video = videoRef.current
if (!video) return if (!video) return
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { ([entry]) => {
entries.forEach((entry) => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
// Video ekranda görünür - oynat video.play().catch(() => {
video.play().catch((err) => { // Tarayıcı otomatik oynatmayı engelledi — sessizce yoksay
console.log('Video autoplay failed:', err)
}) })
} else { } else {
// Video ekrandan çıktı - durdur
video.pause() video.pause()
} }
})
},
{
threshold: 0.5, // Video %50 görünür olduğunda oynat
}, },
{ threshold: 0.5 },
) )
observer.observe(video) observer.observe(video)
return () => observer.disconnect()
return () => { }, [mediaType])
observer.disconnect()
}
}, [post.media?.type])
const handleSubmitComment = (e: React.FormEvent) => { const handleSubmitComment = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (commentText.trim()) { const trimmed = commentText.trim()
onComment(post.id, commentText) if (!trimmed) return
onComment(post.id, trimmed)
setCommentText('') setCommentText('')
} }
}
const getImageLayout = (images: string[]) => { const renderImages = (urls: string[]) => {
const count = images.length const { grid, cell } = imageGridClass(urls.length)
if (count === 1) return 'single' const displayImages = showAllImages ? urls : urls.slice(0, MAX_VISIBLE_IMAGES)
if (count === 2) return 'double' const hiddenCount = urls.length - MAX_VISIBLE_IMAGES
if (count === 3) return 'triple'
return 'multiple'
}
const renderMedia = () => {
if (!post.media) return null
switch (post.media.type) {
case 'image':
if (post.media.urls && post.media.urls.length > 0) {
const layout = getImageLayout(post.media.urls)
const displayImages = showAllImages ? post.media.urls : post.media.urls.slice(0, 4)
const hasMore = post.media.urls.length > 4
return ( return (
<> <>
<div <div className={classNames('mt-3 rounded-lg overflow-hidden', grid)}>
className={classNames('mt-3 rounded-lg overflow-hidden', {
'grid gap-1': layout !== 'single',
'grid-cols-2': layout === 'double' || layout === 'multiple',
'grid-cols-3': layout === 'triple',
})}
>
{displayImages.map((url, index) => ( {displayImages.map((url, index) => (
<div <div
key={index} key={`${index}-${url}`}
className={classNames('relative', { className={classNames('relative', cell, {
'col-span-2': layout === 'triple' && index === 0, 'col-span-2': urls.length === 3 && index === 0,
'aspect-video': layout === 'single',
'aspect-square': layout !== 'single',
})} })}
> >
<img <img
src={url} src={url}
alt={`Post image ${index + 1}`} alt={`${postUserFullName} ${index + 1}`}
loading="lazy"
className="w-full h-full object-cover cursor-pointer hover:opacity-90 transition-opacity" className="w-full h-full object-cover cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => { onClick={() => {
setLightboxIndex(index) setLightboxIndex(index)
setLightboxOpen(true) setLightboxOpen(true)
}} }}
/> />
{hasMore && index === 3 && !showAllImages && post.media?.urls && ( {hiddenCount > 0 && index === MAX_VISIBLE_IMAGES - 1 && !showAllImages && (
<div <div
className="absolute inset-0 bg-black bg-opacity-60 flex items-center justify-center cursor-pointer" className="absolute inset-0 bg-black bg-opacity-60 flex items-center justify-center cursor-pointer"
onClick={(e) => { onClick={(e) => {
@ -136,78 +132,80 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
setShowAllImages(true) setShowAllImages(true)
}} }}
> >
<span className="text-white text-2xl font-bold"> <span className="text-white text-2xl font-bold">+{hiddenCount}</span>
+{post.media.urls.length - 4}
</span>
</div> </div>
)} )}
</div> </div>
))} ))}
</div> </div>
{lightboxOpen && (
<MediaLightbox <MediaLightbox
isOpen={lightboxOpen} isOpen
onClose={() => setLightboxOpen(false)} onClose={() => setLightboxOpen(false)}
media={{ type: 'image', urls: post.media.urls }} media={{ type: 'image', urls }}
startIndex={lightboxIndex} startIndex={lightboxIndex}
/> />
)}
</> </>
) )
} }
break
case 'video': const renderVideo = (url: string) => (
if (post.media.urls && post.media.urls.length > 0) {
return (
<> <>
<div <div className="mt-3 rounded-lg overflow-hidden relative group">
className="mt-3 rounded-lg overflow-hidden cursor-pointer relative group"
onClick={() => setLightboxOpen(true)}
>
<video <video
ref={videoRef} ref={videoRef}
src={post.media.urls[0]} src={url}
className="w-full max-h-96 object-cover" className="w-full max-h-96 object-cover"
controls controls
playsInline playsInline
muted muted
loop loop
/> />
</div> {/* `controls` ile çakışmaması için tam ekran ayrı bir düğmede */}
<MediaLightbox <Button
isOpen={lightboxOpen} type="button"
onClose={() => setLightboxOpen(false)} onClick={() => setLightboxOpen(true)}
media={{ type: 'video', urls: [post.media.urls[0]] }} variant="plain"
shape="none"
icon={<FaExpand className="h-4 w-4" />}
className="absolute top-2 right-2 !h-7 !w-7 !rounded-lg !px-0 bg-black/50 text-white opacity-0 transition-opacity hover:!bg-black/70 group-hover:opacity-100"
title={translate('::App.Platform.Intranet.SocialWall.PostItem.Fullscreen')}
/> />
</div>
{lightboxOpen && (
<MediaLightbox
isOpen
onClose={() => setLightboxOpen(false)}
media={{ type: 'video', urls: [url] }}
/>
)}
</> </>
) )
}
break
case 'poll': const renderPoll = () => {
if (post.media.pollQuestion && post.media.pollOptions) { const media = post.media
const pollEndsAt = post.media.pollEndsAt ? new Date(post.media.pollEndsAt) : null if (!media?.pollQuestion || !media.pollOptions) return null
const pollEndsAt = media.pollEndsAt ? new Date(media.pollEndsAt) : null
const isExpired = pollEndsAt ? new Date() > pollEndsAt : false const isExpired = pollEndsAt ? new Date() > pollEndsAt : false
const hasVoted = !!post.media.pollUserVoteId const hasVoted = !!media.pollUserVoteId
const totalVotes = post.media.pollTotalVotes || 0 const totalVotes = media.pollTotalVotes || 0
const pollUserVoteId = post.media.pollUserVoteId const isLocked = hasVoted || isExpired
return ( return (
<div className="mt-3 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg"> <div className="mt-3 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
<h4 className="font-medium text-gray-900 dark:text-gray-100 mb-3"> <h4 className="font-medium text-gray-900 dark:text-gray-100 mb-3">{media.pollQuestion}</h4>
{post.media.pollQuestion}
</h4>
<div className="space-y-2"> <div className="space-y-2">
{post.media.pollOptions.map((option) => { {media.pollOptions.map((option) => {
const percentage = totalVotes > 0 ? ((option.votes ?? 0) / totalVotes) * 100 : 0 const percentage = totalVotes > 0 ? ((option.votes ?? 0) / totalVotes) * 100 : 0
const isSelected = pollUserVoteId === option.id const isSelected = media.pollUserVoteId === option.id
return ( return (
<Button <Button
key={option.id} key={option.id}
onClick={() => onClick={() => option.id && !isLocked && onVote(post.id, option.id)}
option.id && !hasVoted && !isExpired && onVote(post.id, option.id) disabled={isLocked}
}
disabled={hasVoted || isExpired}
variant="plain" variant="plain"
shape="none" shape="none"
className={classNames( className={classNames(
@ -215,8 +213,8 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
{ {
'!bg-blue-100 dark:!bg-blue-900 border-2 border-blue-500': isSelected, '!bg-blue-100 dark:!bg-blue-900 border-2 border-blue-500': isSelected,
'bg-white dark:bg-gray-600 hover:!bg-gray-50 dark:hover:!bg-gray-500': 'bg-white dark:bg-gray-600 hover:!bg-gray-50 dark:hover:!bg-gray-500':
!isSelected && !hasVoted && !isExpired, !isSelected && !isLocked,
'bg-white dark:bg-gray-600 cursor-not-allowed': hasVoted || isExpired, 'bg-white dark:bg-gray-600 cursor-not-allowed': isLocked,
}, },
)} )}
> >
@ -241,21 +239,42 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
})} })}
</div> </div>
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400"> <div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
{totalVotes} oy {' '} {translate('::App.Platform.Intranet.SocialWall.PostItem.VoteCount', {
{isExpired count: totalVotes,
? 'Sona erdi' })}
: pollEndsAt {isExpired && (
? dayjs(pollEndsAt).fromNow() + ' bitiyor' <> {translate('::App.Platform.Intranet.SocialWall.PostItem.PollEnded')}</>
: ''} )}
{!isExpired && pollEndsAt && (
<>
{' • '}
{translate('::App.Platform.Intranet.SocialWall.PostItem.PollEndsIn', {
time: dayjs(pollEndsAt).fromNow(true),
})}
</>
)}
</div> </div>
</div> </div>
) )
} }
break
}
const renderMedia = () => {
const media = post.media
if (!media) return null
const urls = (media.urls ?? []).filter(Boolean)
switch (media.type) {
case 'image':
return urls.length > 0 ? renderImages(urls) : null
case 'video':
return urls.length > 0 ? renderVideo(urls[0]) : null
case 'poll':
return renderPoll()
default:
return null return null
} }
}
return ( return (
<motion.div <motion.div
@ -313,7 +332,7 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
<div className="mb-3"> <div className="mb-3">
<div <div
className="whitespace-pre-wrap text-gray-800 dark:text-gray-200" className="whitespace-pre-wrap text-gray-800 dark:text-gray-200"
dangerouslySetInnerHTML={{ __html: post.content || '' }} dangerouslySetInnerHTML={{ __html: sanitizedContent }}
/> />
{renderMedia()} {renderMedia()}
</div> </div>
@ -331,19 +350,18 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
<FaRegHeart className="h-5 w-5" /> <FaRegHeart className="h-5 w-5" />
) )
} }
className={classNames( className={ACTION_BUTTON_CLASS}
'!h-auto !items-center gap-2 !rounded-none !border-0 !bg-transparent !px-0 !py-0 text-gray-600 transition-colors hover:!bg-transparent active:!bg-transparent focus:!bg-transparent dark:text-gray-400',
)}
> >
<span className="text-sm font-medium">{postLikeCount}</span> <span className="text-sm font-medium">{postLikeCount}</span>
</Button> </Button>
<Button <Button
onClick={() => setShowComments(!showComments)} onClick={() => setShowComments((prev) => !prev)}
variant="plain" variant="plain"
shape="none" shape="none"
icon={<FaRegCommentAlt className="h-5 w-5" />} icon={<FaRegCommentAlt className="h-5 w-5" />}
className="!h-auto !items-center gap-2 !rounded-none !border-0 !bg-transparent !px-0 !py-0 text-gray-600 transition-colors hover:!bg-transparent hover:text-blue-600 active:!bg-transparent focus:!bg-transparent dark:text-gray-400" className={classNames(ACTION_BUTTON_CLASS, 'hover:text-blue-600')}
aria-expanded={showComments}
> >
<span className="text-sm font-medium">{postComments.length}</span> <span className="text-sm font-medium">{postComments.length}</span>
</Button> </Button>
@ -398,11 +416,11 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
{hoveredCommentAuthor === comment.id && ( {hoveredCommentAuthor === comment.id && (
<UserProfileCard <UserProfileCard
user={{ user={{
id: comment.user?.id || '', id: comment.user?.id ?? '',
name: comment.user?.fullName || '', name: comment.user?.fullName ?? '',
title: comment.user?.jobPositions?.[0]?.name || '', title: comment.user?.jobPositions?.[0]?.name ?? '',
phoneNumber: comment.user.phoneNumber, phoneNumber: comment.user?.phoneNumber,
tenantId: comment.user?.tenantId || '', tenantId: comment.user?.tenantId ?? '',
}} }}
position="bottom" position="bottom"
/> />

View file

@ -1,46 +1,90 @@
import React, { useState, useEffect, useRef, useCallback } from 'react' import React, { useState, useEffect, useRef, useCallback } from 'react'
import { useLocalization } from '@/utils/hooks/useLocalization' import { useLocalization } from '@/utils/hooks/useLocalization'
import { AnimatePresence } from 'framer-motion' import { AnimatePresence } from 'framer-motion'
import classNames from 'classnames'
import PostItem from './PostItem' import PostItem from './PostItem'
import CreatePost from './CreatePost' import CreatePost from './CreatePost'
import { SocialMediaDto, SocialPostDto } from '@/proxy/intranet/models' import { SocialMediaDto, SocialPostDto } from '@/proxy/intranet/models'
import { intranetService } from '@/services/intranet.service' import { intranetService } from '@/services/intranet.service'
import type { CreateSocialPostInput } from '@/services/intranet.service'
import Button from '@/components/ui/Button' import Button from '@/components/ui/Button'
import type { DashboardWidgetDefinition } from '../dashboardWidget' import type { DashboardWidgetDefinition } from '../dashboardWidget'
const PAGE_SIZE = 10 const PAGE_SIZE = 10
type PostFilter = 'all' | 'mine'
const TAB_CLASS =
'!h-auto !rounded-none border-b-2 !bg-transparent !px-1 !pb-3 !pt-0 font-medium transition-colors hover:!bg-transparent active:!bg-transparent focus:!bg-transparent'
const buildMediaInput = (
media?: {
type: 'mixed' | 'poll'
mediaItems?: SocialMediaDto[]
poll?: { question: string; options: Array<{ text: string }> }
},
): CreateSocialPostInput['media'] => {
if (!media) return undefined
if (media.type === 'poll' && media.poll) {
return {
type: 'poll',
pollQuestion: media.poll.question,
pollOptions: media.poll.options,
}
}
const items = media.mediaItems ?? []
if (items.length === 0) return undefined
// Backend tek bir medya kaydı tutar: tüm öğeler aynı tipte olmak zorunda
// (MediaManager bunu zorunlu kılar), tip ilk öğeden türetilir.
const type = items[0].type === 'video' ? 'video' : 'image'
const urls = items.flatMap((item) => item.urls ?? []).filter(Boolean)
return urls.length > 0 ? { type, urls } : undefined
}
const SocialWall: React.FC = () => { const SocialWall: React.FC = () => {
const { translate } = useLocalization()
const [posts, setPosts] = useState<SocialPostDto[]>([]) const [posts, setPosts] = useState<SocialPostDto[]>([])
const [skipCount, setSkipCount] = useState(0) const [filter, setFilter] = useState<PostFilter>('all')
const [hasMore, setHasMore] = useState(true) const [hasMore, setHasMore] = useState(true)
const [loadingMore, setLoadingMore] = useState(false) const [loadingMore, setLoadingMore] = useState(false)
const sentinelRef = useRef<HTMLDivElement>(null) const sentinelRef = useRef<HTMLDivElement>(null)
const skipCountRef = useRef(0)
const loadingRef = useRef(false)
const hasMoreRef = useRef(true)
const loadMore = useCallback(async () => { const loadMore = useCallback(async () => {
if (loadingMore || !hasMore) return if (loadingRef.current || !hasMoreRef.current) return
loadingRef.current = true
setLoadingMore(true) setLoadingMore(true)
try { try {
const res = await intranetService.getIntranetSocialPosts(skipCount, PAGE_SIZE) const res = await intranetService.getIntranetSocialPosts(skipCountRef.current, PAGE_SIZE)
const newPosts = res.data ?? [] const fetched = res.data ?? []
skipCountRef.current += fetched.length
hasMoreRef.current = fetched.length === PAGE_SIZE
setHasMore(hasMoreRef.current)
setPosts((prev) => { setPosts((prev) => {
const existingIds = new Set(prev.map((p) => p.id)) const existingIds = new Set(prev.map((p) => p.id))
const unique = newPosts.filter((p) => !existingIds.has(p.id)) return [...prev, ...fetched.filter((p) => !existingIds.has(p.id))]
return [...prev, ...unique]
}) })
setSkipCount((s) => s + newPosts.length)
setHasMore(newPosts.length === PAGE_SIZE)
} catch { } catch {
// error handled by apiService // hata apiService tarafından ele alınıyor
} finally { } finally {
loadingRef.current = false
setLoadingMore(false) setLoadingMore(false)
} }
}, [loadingMore, hasMore, skipCount]) }, [])
// Sentinel observer — ilk yükleme de dahil tüm sayfaları bu tetikler // Sentinel observer — ilk yükleme dahil tüm sayfaları bu tetikler.
// posts.length bağımlılığı, sentinel hâlâ görünür durumdaysa bir sonraki
// sayfanın da yüklenmesini sağlar.
useEffect(() => { useEffect(() => {
const sentinel = sentinelRef.current const sentinel = sentinelRef.current
if (!sentinel) return if (!sentinel || !hasMore) return
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
if (entries[0].isIntersecting) loadMore() if (entries[0].isIntersecting) loadMore()
@ -49,113 +93,64 @@ const SocialWall: React.FC = () => {
) )
observer.observe(sentinel) observer.observe(sentinel)
return () => observer.disconnect() return () => observer.disconnect()
}, [loadMore]) }, [loadMore, hasMore, posts.length])
const [filter, setFilter] = useState<'all' | 'mine'>('all')
const { translate } = useLocalization()
const updatePosts = (updated: SocialPostDto[]) => { const handleCreatePost: React.ComponentProps<typeof CreatePost>['onCreatePost'] = async ({
setPosts(updated) content,
} media,
const handleCreatePost = async (postData: {
content: string
location?: string
media?: {
type: 'mixed' | 'poll'
mediaItems?: SocialMediaDto[]
poll?: {
question: string
options: Array<{ text: string }>
}
}
}) => { }) => {
let mediaInput:
| {
type: 'image' | 'video' | 'poll'
urls?: string[]
pollQuestion?: string
pollOptions?: { text: string }[]
}
| undefined
if (postData.media) {
if (postData.media.type === 'mixed' && postData.media.mediaItems) {
const images = postData.media.mediaItems.filter((m) => m.type === 'image')
const videos = postData.media.mediaItems.filter((m) => m.type === 'video')
if (images.length > 0 && videos.length === 0) {
mediaInput = {
type: 'image',
urls: images.map((i) => i.urls?.[0]).filter(Boolean) as string[],
}
} else if (videos.length > 0 && images.length === 0) {
mediaInput = { type: 'video', urls: videos[0].urls || [] }
} else if (images.length > 0 || videos.length > 0) {
mediaInput = {
type: 'image',
urls: images.map((i) => i.urls?.[0]).filter(Boolean) as string[],
}
}
} else if (postData.media.type === 'poll' && postData.media.poll) {
mediaInput = {
type: 'poll',
pollQuestion: postData.media.poll.question,
pollOptions: postData.media.poll.options,
}
}
}
try { try {
const response = await intranetService.createSocialPost({ const response = await intranetService.createSocialPost({
content: postData.content, content,
locationJson: postData.location, media: buildMediaInput(media),
media: mediaInput,
}) })
updatePosts([response.data, ...posts]) // Yeni gönderi listenin başına eklendiği için sayfalama ofseti de kaymalı
skipCountRef.current += 1
setPosts((prev) => [response.data, ...prev])
} catch { } catch {
// error handled by apiService // hata apiService tarafından ele alınıyor
} }
} }
const handleLike = async (postId: string) => { const handleLike = async (postId: string) => {
try { try {
const response = await intranetService.likeSocialPost(postId) const response = await intranetService.likeSocialPost(postId)
updatePosts(posts.map((p) => (p.id === postId ? response.data : p))) setPosts((prev) => prev.map((p) => (p.id === postId ? response.data : p)))
} catch { } catch {
// error handled by apiService // hata apiService tarafından ele alınıyor
} }
} }
const handleComment = async (postId: string, content: string) => { const handleComment = async (postId: string, content: string) => {
try { try {
const response = await intranetService.commentSocialPost(postId, content) const response = await intranetService.commentSocialPost(postId, content)
updatePosts( setPosts((prev) =>
posts.map((p) => prev.map((p) =>
p.id === postId ? { ...p, comments: [...(p.comments || []), response.data] } : p, p.id === postId ? { ...p, comments: [...(p.comments || []), response.data] } : p,
), ),
) )
} catch { } catch {
// error handled by apiService // hata apiService tarafından ele alınıyor
} }
} }
const handleDelete = async (postId: string) => { const handleDelete = async (postId: string) => {
if (window.confirm(translate('::App.Platform.Intranet.SocialWall.DeleteConfirm'))) { if (!window.confirm(translate('::App.Platform.Intranet.SocialWall.DeleteConfirm'))) return
try { try {
await intranetService.deleteSocialPost(postId) await intranetService.deleteSocialPost(postId)
updatePosts(posts.filter((p) => p.id !== postId)) skipCountRef.current = Math.max(0, skipCountRef.current - 1)
setPosts((prev) => prev.filter((p) => p.id !== postId))
} catch { } catch {
// error handled by apiService // hata apiService tarafından ele alınıyor
}
} }
} }
const handleVote = async (postId: string, optionId: string) => { const handleVote = async (postId: string, optionId: string) => {
try { try {
await intranetService.voteSocialPoll(postId, optionId) await intranetService.voteSocialPoll(postId, optionId)
updatePosts( setPosts((prev) =>
posts.map((p) => { prev.map((p) => {
if (p.id === postId && p.media?.type === 'poll' && p.media.pollOptions) { if (p.id !== postId || p.media?.type !== 'poll' || !p.media.pollOptions) return p
if (p.media.pollUserVoteId) return p if (p.media.pollUserVoteId) return p
return { return {
...p, ...p,
@ -168,45 +163,38 @@ const SocialWall: React.FC = () => {
pollUserVoteId: optionId, pollUserVoteId: optionId,
}, },
} }
}
return p
}), }),
) )
} catch { } catch {
// error handled by apiService // hata apiService tarafından ele alınıyor
} }
} }
const filteredPosts = filter === 'mine' ? posts.filter((post) => post.isOwnPost) : posts const filteredPosts = filter === 'mine' ? posts.filter((post) => post.isOwnPost) : posts
const renderTab = (value: PostFilter, labelKey: string) => (
<Button
onClick={() => setFilter(value)}
variant="plain"
shape="none"
aria-pressed={filter === value}
className={classNames(
TAB_CLASS,
filter === value
? '!border-b-blue-600 !text-blue-600'
: '!border-transparent text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200',
)}
>
{translate(labelKey)}
</Button>
)
return ( return (
<div className="mx-auto px-4"> <div className="mx-auto px-4">
{/* Filter Tabs */} {/* Filter Tabs */}
<div className="flex gap-4 mb-6 border-b border-gray-200 dark:border-gray-700"> <div className="flex gap-4 mb-6 border-b border-gray-200 dark:border-gray-700">
<Button {renderTab('all', '::App.Platform.Intranet.SocialWall.AllPosts')}
onClick={() => setFilter('all')} {renderTab('mine', '::App.Platform.Intranet.SocialWall.MyPosts')}
variant="plain"
shape="none"
className={`!h-auto !rounded-none border-b-2 !bg-transparent !px-1 !pb-3 !pt-0 font-medium transition-colors hover:!bg-transparent active:!bg-transparent focus:!bg-transparent ${
filter === 'all'
? '!border-b-blue-600 !text-blue-600'
: '!border-transparent text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
}`}
>
{translate('::App.Platform.Intranet.SocialWall.AllPosts')}
</Button>
<Button
onClick={() => setFilter('mine')}
variant="plain"
shape="none"
className={`!h-auto !rounded-none border-b-2 !bg-transparent !px-1 !pb-3 !pt-0 font-medium transition-colors hover:!bg-transparent active:!bg-transparent focus:!bg-transparent ${
filter === 'mine'
? '!border-b-blue-600 !text-blue-600'
: '!border-transparent text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
}`}
>
{translate('::App.Platform.Intranet.SocialWall.MyPosts')}
</Button>
</div> </div>
{/* Create Post */} {/* Create Post */}
@ -214,8 +202,7 @@ const SocialWall: React.FC = () => {
{/* Posts Feed */} {/* Posts Feed */}
<AnimatePresence> <AnimatePresence>
{filteredPosts.length > 0 ? ( {filteredPosts.map((post) => (
filteredPosts.map((post) => (
<PostItem <PostItem
key={post.id} key={post.id}
post={post} post={post}
@ -224,8 +211,10 @@ const SocialWall: React.FC = () => {
onDelete={handleDelete} onDelete={handleDelete}
onVote={handleVote} onVote={handleVote}
/> />
)) ))}
) : ( </AnimatePresence>
{filteredPosts.length === 0 && !loadingMore && (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-500 dark:text-gray-400 text-lg"> <p className="text-gray-500 dark:text-gray-400 text-lg">
{filter === 'mine' {filter === 'mine'
@ -234,11 +223,8 @@ const SocialWall: React.FC = () => {
</p> </p>
</div> </div>
)} )}
</AnimatePresence>
{/* Infinite scroll sentinel */} {/* Infinite scroll sentinel */}
{filter === 'all' && (
<>
<div ref={sentinelRef} className="h-1" /> <div ref={sentinelRef} className="h-1" />
{loadingMore && ( {loadingMore && (
<div className="flex justify-center py-6"> <div className="flex justify-center py-6">
@ -247,12 +233,9 @@ const SocialWall: React.FC = () => {
)} )}
{!hasMore && posts.length > 0 && ( {!hasMore && posts.length > 0 && (
<p className="text-center text-sm text-gray-400 dark:text-gray-600 py-6"> <p className="text-center text-sm text-gray-400 dark:text-gray-600 py-6">
{translate('::App.Platform.Intranet.SocialWall.AllPostsLoaded') || {translate('::App.Platform.Intranet.SocialWall.AllPostsLoaded')}
'Tüm gönderiler yüklendi'}
</p> </p>
)} )}
</>
)}
</div> </div>
) )
} }

View file

@ -17,11 +17,10 @@ const Surveys: React.FC<SurveysProps> = ({ surveys, onTakeSurvey }) => {
const currentLocale = useLocale() const currentLocale = useLocale()
const { translate } = useLocalization() const { translate } = useLocalization()
const [hoveredSurveyId, setHoveredSurveyId] = useState<string | null>(null) const [hoveredSurveyId, setHoveredSurveyId] = useState<string | null>(null)
const surveyList = surveys ?? []
return ( return (
<div className="bg-gradient-to-br from-white to-gray-50 dark:from-gray-800 dark:to-gray-900 rounded-xl shadow-lg border border-gray-200/50 dark:border-gray-700/50 overflow-hidden"> <div className="bg-gradient-to-br from-white to-gray-50 dark:from-gray-800 dark:to-gray-900 rounded-xl shadow-lg border border-gray-200/50 dark:border-gray-700/50 overflow-hidden">
{/* Header with gradient */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800"> <div className="p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<h2 className="text-base font-semibold text-gray-900 dark:text-white flex items-center gap-2"> <h2 className="text-base font-semibold text-gray-900 dark:text-white flex items-center gap-2">
<FaClipboardCheck className="w-5 h-5" /> <FaClipboardCheck className="w-5 h-5" />
@ -30,7 +29,7 @@ const Surveys: React.FC<SurveysProps> = ({ surveys, onTakeSurvey }) => {
</div> </div>
<div className="p-3 space-y-4 bg-white dark:bg-gray-800"> <div className="p-3 space-y-4 bg-white dark:bg-gray-800">
{surveys?.map((survey) => { {surveyList.map((survey) => {
const daysLeft = dayjs(survey.deadline).diff(dayjs(), 'day') const daysLeft = dayjs(survey.deadline).diff(dayjs(), 'day')
const urgency = daysLeft <= 3 ? 'urgent' : daysLeft <= 7 ? 'warning' : 'normal' const urgency = daysLeft <= 3 ? 'urgent' : daysLeft <= 7 ? 'warning' : 'normal'
const isCompleted = !!survey.myResponse const isCompleted = !!survey.myResponse
@ -114,7 +113,7 @@ const Surveys: React.FC<SurveysProps> = ({ surveys, onTakeSurvey }) => {
</div> </div>
{/* Survey Stats */} {/* Survey Stats */}
<div className="grid grid-cols-3 gap-4 mb-4"> <div className="grid grid-cols-2 gap-4 mb-4">
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<div className="p-1.5 bg-blue-100 dark:bg-blue-900/40 rounded-lg"> <div className="p-1.5 bg-blue-100 dark:bg-blue-900/40 rounded-lg">
<FaQuestionCircle className="w-3 h-3 text-blue-600 dark:text-blue-300" /> <FaQuestionCircle className="w-3 h-3 text-blue-600 dark:text-blue-300" />
@ -143,35 +142,6 @@ const Surveys: React.FC<SurveysProps> = ({ surveys, onTakeSurvey }) => {
</div> </div>
</div> </div>
<div className="flex items-center gap-2 text-sm">
<div className="p-1.5 bg-purple-100 dark:bg-purple-900/40 rounded-lg">
<FaClock className="w-3 h-3 text-purple-600 dark:text-purple-300" />
</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">
{translate('::App.Platform.Intranet.Widgets.ActiveSurveys.Duration')}
</p>
<p className="font-semibold text-gray-900 dark:text-white">~5dk</p>
</div>
</div>
</div>
{/* Progress Bar */}
<div className="mb-4">
<div className="flex justify-between text-xs mb-1">
<span className="text-gray-600 dark:text-gray-400">
{translate('::App.Platform.Intranet.Widgets.ActiveSurveys.CompletionRate')}
</span>
<span className="text-gray-800 dark:text-gray-100 font-medium">
{Math.round((survey.responses / 100) * 100)}%
</span>
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-gradient-to-r from-purple-500 to-pink-500 dark:from-purple-700 dark:to-pink-700 h-2 rounded-full transition-all duration-500"
style={{ width: `${Math.min((survey.responses / 100) * 100, 100)}%` }}
></div>
</div>
</div> </div>
{/* Deadline */} {/* Deadline */}
@ -205,7 +175,7 @@ const Surveys: React.FC<SurveysProps> = ({ surveys, onTakeSurvey }) => {
) )
})} })}
{surveys?.length === 0 && ( {surveyList.length === 0 && (
<div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl"> <div className="text-center py-12 bg-white dark:bg-gray-800 rounded-xl">
<div className="inline-flex items-center justify-center w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full mb-4"> <div className="inline-flex items-center justify-center w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full mb-4">
<FaClipboardCheck className="w-8 h-8 text-gray-400 dark:text-gray-500" /> <FaClipboardCheck className="w-8 h-8 text-gray-400 dark:text-gray-500" />

View file

@ -20,8 +20,8 @@ const TodayBirthdays: React.FC<{ employees: UserInfoViewModel[] }> = ({ employee
<div className="p-2 space-y-3"> <div className="p-2 space-y-3">
{employees.length > 0 ? ( {employees.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{employees.map((birthday, index) => ( {employees.map((birthday) => (
<div key={index} className="flex items-center gap-2 p-2"> <div key={birthday.id} className="flex items-center gap-2 p-2">
<Avatar <Avatar
size={48} size={48}
shape="circle" shape="circle"

View file

@ -6,7 +6,6 @@ interface ImportMetaEnv {
readonly VITE_CDN_URL: string readonly VITE_CDN_URL: string
readonly VITE_REACT_APP_VERSION: string readonly VITE_REACT_APP_VERSION: string
readonly VITE_AI_URL: string readonly VITE_AI_URL: string
readonly VITE_GOOGLE_MAPS_API_KEY: string
readonly VITE_USE_POLLING?: string readonly VITE_USE_POLLING?: string
/** Dev sunucusunda service worker'ı açar (varsayılan kapalı). */ /** Dev sunucusunda service worker'ı açar (varsayılan kapalı). */
readonly VITE_PWA_DEV?: string readonly VITE_PWA_DEV?: string