Claude güncellemesi Intranet güncellemesi
This commit is contained in:
parent
5217887bb8
commit
de7e6c1e21
22 changed files with 649 additions and 1516 deletions
|
|
@ -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_REACT_APP_VERSION` | `package.json` sürümünden beslenir. |
|
||||
| `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_PWA_DEV` | Geliştirmede service worker'ı açar (varsayılan kapalı). |
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,6 @@ public class CreateSocialPostInput
|
|||
{
|
||||
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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,22 +15,11 @@ public class SocialPostDto : FullAuditedEntityDto<Guid>
|
|||
public bool IsLiked { get; set; }
|
||||
public bool IsOwnPost { get; set; }
|
||||
|
||||
public SocialLocationDto? Location { get; set; }
|
||||
public SocialMediaDto? Media { get; set; }
|
||||
public List<SocialCommentDto> Comments { 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 Guid SocialPostId { get; set; }
|
||||
|
|
|
|||
|
|
@ -620,7 +620,7 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
|
|||
|
||||
// Sonra sadece bu ID'ler için detayları yükle
|
||||
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(
|
||||
queryable
|
||||
|
|
@ -852,20 +852,6 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
|
|||
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)
|
||||
{
|
||||
var media = new SocialMedia(Guid.NewGuid())
|
||||
|
|
@ -895,7 +881,7 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
|
|||
|
||||
// Reload with full navigation properties for mapping
|
||||
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 dto = ObjectMapper.Map<SocialPost, SocialPostDto>(savedPost!);
|
||||
|
|
@ -956,7 +942,7 @@ public class IntranetAppService : PlatformAppService, IIntranetAppService
|
|||
await _socialPostRepository.UpdateAsync(post, autoSave: true);
|
||||
|
||||
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 dto = ObjectMapper.Map<SocialPost, SocialPostDto>(updated!);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
public partial class SocialMediaToSocialMediaDtoMapper : MapperBase<SocialMedia, SocialMediaDto>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -12852,120 +12852,6 @@
|
|||
"tr": "Tüm gönderiler yüklendi",
|
||||
"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",
|
||||
"key": "App.Platform.Intranet.SocialWall.MediaManager.AddMedia",
|
||||
|
|
@ -13068,6 +12954,30 @@
|
|||
"en": "Write a comment...",
|
||||
"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",
|
||||
"key": "App.Platform.Intranet.Widgets.PriorityTasks.Title",
|
||||
|
|
@ -13248,18 +13158,6 @@
|
|||
"tr": "Yanıtlar",
|
||||
"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",
|
||||
"key": "App.Platform.Intranet.Widgets.ActiveSurveys.FillSurvey",
|
||||
|
|
@ -13644,12 +13542,6 @@
|
|||
"tr": "Tüm medyaları kaldır",
|
||||
"en": "Remove all media"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.Platform.Intranet.SocialWall.CreatePost.RemoveLocationTitle",
|
||||
"tr": "Konumu kaldır",
|
||||
"en": "Remove location"
|
||||
},
|
||||
{
|
||||
"resourceName": "Platform",
|
||||
"key": "App.Platform.Intranet.SocialWall.CreatePost.Poll",
|
||||
|
|
@ -13704,18 +13596,6 @@
|
|||
"tr": "Emoji ekle",
|
||||
"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",
|
||||
"key": "App.Platform.Intranet.SocialWall.CreatePost.Submit",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ using Volo.Abp.EntityFrameworkCore;
|
|||
namespace Sozsoft.Platform.Migrations
|
||||
{
|
||||
[DbContext(typeof(PlatformDbContext))]
|
||||
[Migration("20260808210009_Initial")]
|
||||
[Migration("20260811131124_Initial")]
|
||||
partial class Initial
|
||||
{
|
||||
/// <inheritdoc />
|
||||
|
|
@ -63,7 +63,6 @@ public class TenantSeederDto
|
|||
public List<SurveyQuestionSeedDto> SurveyQuestions { get; set; }
|
||||
public List<SurveyQuestionOptionSeedDto> SurveyQuestionOptions { get; set; }
|
||||
public List<SocialPostSeedDto> SocialPosts { get; set; }
|
||||
public List<SocialLocationSeedDto> SocialLocations { get; set; }
|
||||
public List<SocialMediaSeedDto> SocialMedias { get; set; }
|
||||
public List<SocialPollOptionSeedDto> SocialPollOptions { get; set; }
|
||||
public List<SocialCommentSeedDto> SocialComments { get; set; }
|
||||
|
|
@ -97,16 +96,6 @@ public class SocialPostSeedDto
|
|||
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 string PostContent { get; set; }
|
||||
|
|
@ -521,7 +510,6 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency
|
|||
private readonly IRepository<SurveyQuestion, Guid> _surveyQuestionRepository;
|
||||
private readonly IRepository<SurveyQuestionOption, Guid> _surveyQuestionOptionRepository;
|
||||
private readonly IRepository<SocialPost, Guid> _socialPostRepository;
|
||||
private readonly IRepository<SocialLocation, Guid> _socialLocationRepository;
|
||||
private readonly IRepository<SocialMedia, Guid> _socialMediaRepository;
|
||||
private readonly IRepository<SocialPollOption, Guid> _socialPollOptionRepository;
|
||||
private readonly IRepository<SocialComment, Guid> _socialCommentRepository;
|
||||
|
|
@ -571,7 +559,6 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency
|
|||
IRepository<SurveyQuestion, Guid> surveyQuestionRepository,
|
||||
IRepository<SurveyQuestionOption, Guid> surveyQuestionOptionRepository,
|
||||
IRepository<SocialPost, Guid> socialPostRepository,
|
||||
IRepository<SocialLocation, Guid> socialLocationRepository,
|
||||
IRepository<SocialMedia, Guid> socialMediaRepository,
|
||||
IRepository<SocialPollOption, Guid> socialPollOptionRepository,
|
||||
IRepository<SocialComment, Guid> socialCommentRepository,
|
||||
|
|
@ -620,7 +607,6 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency
|
|||
_surveyQuestionRepository = surveyQuestionRepository;
|
||||
_surveyQuestionOptionRepository = surveyQuestionOptionRepository;
|
||||
_socialPostRepository = socialPostRepository;
|
||||
_socialLocationRepository = socialLocationRepository;
|
||||
_socialMediaRepository = socialMediaRepository;
|
||||
_socialPollOptionRepository = socialPollOptionRepository;
|
||||
_socialCommentRepository = socialCommentRepository;
|
||||
|
|
@ -1162,28 +1148,6 @@ public class TenantDataSeeder : IDataSeedContributor, ITransientDependency
|
|||
}, 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)
|
||||
{
|
||||
var post = await _socialPostRepository.FirstOrDefaultAsync(x => x.Content == item.PostContent);
|
||||
|
|
|
|||
18
ui/.env
18
ui/.env
|
|
@ -2,21 +2,3 @@ VITE_API_URL='https://localhost:44344'
|
|||
VITE_CDN_URL='http://localhost:4005'
|
||||
VITE_REACT_APP_VERSION=$npm_package_version
|
||||
VITE_AI_URL='https://ai.sozsoft.com/webhook/'
|
||||
VITE_GOOGLE_MAPS_API_KEY='AIzaSyAefS2rvF-xwq7OHpZ27UYxXPbMo6OwACc'
|
||||
# Google Cloud Console’da:
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -170,7 +170,6 @@ export interface SocialPostDto {
|
|||
id: string
|
||||
user: UserInfoViewModel
|
||||
content: string
|
||||
locationJson?: string
|
||||
media?: SocialMediaDto
|
||||
likeCount: number
|
||||
isLiked: boolean
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import apiService, { Config } from './api.service'
|
|||
|
||||
export interface CreateSocialPostInput {
|
||||
content: string
|
||||
locationJson?: string
|
||||
media?: {
|
||||
type: 'image' | 'video' | 'poll'
|
||||
urls?: string[]
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import { useLocalization } from '@/utils/hooks/useLocalization'
|
|||
import useLocale from '@/utils/hooks/useLocale'
|
||||
import { currentLocalDate } from '@/utils/dateUtils'
|
||||
import { useStoreActions, useStoreState } from '@/store/store'
|
||||
import type { DashboardLayout } from '@/store/admin.model'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { LuX } from 'react-icons/lu'
|
||||
import type { DashboardWidgetColumn, DashboardWidgetDefinition } from './dashboardWidget'
|
||||
|
|
@ -73,8 +72,6 @@ const layoutPresets = [
|
|||
},
|
||||
] as const
|
||||
|
||||
type DashboardLayoutId = DashboardLayout
|
||||
|
||||
const columnSpanClasses: Record<number, string> = {
|
||||
2: 'lg:col-span-2',
|
||||
3: 'lg:col-span-3',
|
||||
|
|
@ -105,9 +102,13 @@ const IntranetDashboard: React.FC = () => {
|
|||
const currentLocale = useLocale()
|
||||
|
||||
const fetchIntranetDashboard = async () => {
|
||||
const dashboard = await intranetService.getDashboard()
|
||||
if (dashboard.data) {
|
||||
setIntranetDashboard(dashboard.data)
|
||||
try {
|
||||
const dashboard = await intranetService.getDashboard()
|
||||
if (dashboard.data) {
|
||||
setIntranetDashboard(dashboard.data)
|
||||
}
|
||||
} catch {
|
||||
// hata apiService tarafından ele alınıyor
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -199,34 +200,27 @@ const IntranetDashboard: React.FC = () => {
|
|||
if (!grantedPolicies) return
|
||||
|
||||
const hasSavedOrder = dashboardColumns.some((column) => widgetOrder[column].length > 0)
|
||||
if (hasSavedOrder) {
|
||||
try {
|
||||
const parsed = widgetOrder
|
||||
const order: Record<DashboardWidgetColumn, string[]> = {
|
||||
left: [...new Set((parsed.left || []) as string[])],
|
||||
center: [...new Set((parsed.center || []) as string[])],
|
||||
right: [...new Set((parsed.right || []) as string[])],
|
||||
}
|
||||
|
||||
const allAssigned = new Set([...order.left, ...order.center, ...order.right])
|
||||
dashboardWidgets.forEach((w) => {
|
||||
if (!allAssigned.has(w.id) && checkPermission(w.permission)) {
|
||||
order[w.column as keyof typeof order].push(w.id)
|
||||
}
|
||||
})
|
||||
|
||||
setWidgetOrder(order)
|
||||
} catch {
|
||||
initializeDefaultOrder()
|
||||
}
|
||||
} else {
|
||||
if (!hasSavedOrder) {
|
||||
initializeDefaultOrder()
|
||||
return
|
||||
}
|
||||
}, [grantedPolicies])
|
||||
|
||||
const saveWidgetOrder = (newOrder: Record<DashboardWidgetColumn, string[]>) => {
|
||||
setWidgetOrder(newOrder)
|
||||
}
|
||||
// Kayıtlı sıralamayı tekilleştir ve henüz yerleştirilmemiş widget'ları ekle
|
||||
const order: Record<DashboardWidgetColumn, string[]> = {
|
||||
left: [...new Set(widgetOrder.left || [])],
|
||||
center: [...new Set(widgetOrder.center || [])],
|
||||
right: [...new Set(widgetOrder.right || [])],
|
||||
}
|
||||
|
||||
const allAssigned = new Set([...order.left, ...order.center, ...order.right])
|
||||
dashboardWidgets.forEach((widget) => {
|
||||
if (!allAssigned.has(widget.id) && checkPermission(widget.permission)) {
|
||||
order[widget.column].push(widget.id)
|
||||
}
|
||||
})
|
||||
|
||||
setWidgetOrder(order)
|
||||
}, [grantedPolicies])
|
||||
|
||||
const setWidgetVisibility = (widgetId: string, visible: boolean) => {
|
||||
const next = visible
|
||||
|
|
@ -235,10 +229,6 @@ const IntranetDashboard: React.FC = () => {
|
|||
setHiddenWidgetIds(next)
|
||||
}
|
||||
|
||||
const selectLayout = (layoutId: DashboardLayoutId) => {
|
||||
setDashboardLayout(layoutId)
|
||||
}
|
||||
|
||||
const activeLayout =
|
||||
layoutPresets.find((layout) => layout.id === selectedLayout) || layoutPresets[0]
|
||||
const hiddenWidgets = dashboardWidgets.filter(
|
||||
|
|
@ -295,7 +285,7 @@ const IntranetDashboard: React.FC = () => {
|
|||
newOrder[targetColumn].push(widgetId)
|
||||
}
|
||||
|
||||
saveWidgetOrder(newOrder)
|
||||
setWidgetOrder(newOrder)
|
||||
setDragState({ draggedId: null, targetColumn: null, targetIndex: null })
|
||||
}
|
||||
|
||||
|
|
@ -532,7 +522,7 @@ const IntranetDashboard: React.FC = () => {
|
|||
type="button"
|
||||
variant="plain"
|
||||
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'}`}
|
||||
title={`${translate(layout.labelKey)} (${layout.columns.join(' / ')})`}
|
||||
aria-label={`${translate(layout.labelKey)} (${layout.columns.join(' / ')})`}
|
||||
|
|
|
|||
|
|
@ -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 classNames from 'classnames'
|
||||
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 LocationPicker from './LocationPicker'
|
||||
import { SocialMediaDto } from '@/proxy/intranet/models'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { useStoreState } from '@/store/store'
|
||||
import { Avatar, Button } from '@/components/ui'
|
||||
import { AVATAR_URL } from '@/constants/app.constant'
|
||||
|
||||
const MAX_POLL_OPTIONS = 6
|
||||
const MIN_POLL_OPTIONS = 2
|
||||
|
||||
interface CreatePostProps {
|
||||
onCreatePost: (post: {
|
||||
content: string
|
||||
location?: string
|
||||
media?: {
|
||||
type: 'mixed' | 'poll'
|
||||
mediaItems?: SocialMediaDto[]
|
||||
|
|
@ -28,103 +29,97 @@ interface CreatePostProps {
|
|||
|
||||
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 { translate } = useLocalization()
|
||||
const [content, setContent] = useState('')
|
||||
const [mediaType, setMediaType] = useState<'media' | 'poll' | null>(null)
|
||||
const [mediaItems, setMediaItems] = useState<SocialMediaDto[]>([])
|
||||
const [location, setLocation] = useState<string | null>(null)
|
||||
const [pollQuestion, setPollQuestion] = useState('')
|
||||
const [pollOptions, setPollOptions] = useState(['', ''])
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
const [showMediaManager, setShowMediaManager] = useState(false)
|
||||
const [showLocationPicker, setShowLocationPicker] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const emojiPickerRef = useRef<HTMLDivElement>(null)
|
||||
const emojiContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { user, tenant } = useStoreState((state) => state.auth)
|
||||
const theme = useStoreState((state) => state.theme)
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const filledPollOptions = useMemo(
|
||||
() => 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
|
||||
|
||||
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
|
||||
const resetForm = () => {
|
||||
setContent('')
|
||||
setMediaType(null)
|
||||
setMediaItems([])
|
||||
setLocation(null)
|
||||
setPollQuestion('')
|
||||
setPollOptions(['', ''])
|
||||
setIsExpanded(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 emoji = emojiData.emoji
|
||||
const textarea = textareaRef.current
|
||||
if (!textarea) return
|
||||
|
||||
const { emoji } = emojiData
|
||||
const start = textarea.selectionStart
|
||||
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
|
||||
setTimeout(() => {
|
||||
// Emoji eklendikten sonra imleci emojinin sonuna taşı
|
||||
requestAnimationFrame(() => {
|
||||
textarea.selectionStart = textarea.selectionEnd = start + emoji.length
|
||||
textarea.focus()
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
|
||||
const addPollOption = () => {
|
||||
if (pollOptions.length < 6) {
|
||||
setPollOptions([...pollOptions, ''])
|
||||
}
|
||||
}
|
||||
const addPollOption = () =>
|
||||
setPollOptions((prev) => (prev.length < MAX_POLL_OPTIONS ? [...prev, ''] : prev))
|
||||
|
||||
const removePollOption = (index: number) => {
|
||||
if (pollOptions.length > 2) {
|
||||
setPollOptions(pollOptions.filter((_, i) => i !== index))
|
||||
}
|
||||
}
|
||||
const removePollOption = (index: number) =>
|
||||
setPollOptions((prev) =>
|
||||
prev.length > MIN_POLL_OPTIONS ? prev.filter((_, i) => i !== index) : prev,
|
||||
)
|
||||
|
||||
const updatePollOption = (index: number, value: string) => {
|
||||
const newOptions = [...pollOptions]
|
||||
newOptions[index] = value
|
||||
setPollOptions(newOptions)
|
||||
}
|
||||
const updatePollOption = (index: number, value: string) =>
|
||||
setPollOptions((prev) => prev.map((option, i) => (i === index ? value : option)))
|
||||
|
||||
const clearMedia = () => {
|
||||
setMediaType(null)
|
||||
|
|
@ -135,24 +130,21 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
|||
|
||||
const removeMediaItem = (id: string | undefined) => {
|
||||
if (!id) return
|
||||
setMediaItems(mediaItems.filter((m) => m.id !== id))
|
||||
setMediaItems((prev) => prev.filter((m) => m.id !== id))
|
||||
}
|
||||
|
||||
// Close emoji picker when clicking outside
|
||||
React.useEffect(() => {
|
||||
// Emoji seçici dışına tıklanınca kapat (tetikleyici buton da kapsayıcının içinde)
|
||||
useEffect(() => {
|
||||
if (!showEmojiPicker) return
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (emojiPickerRef.current && !emojiPickerRef.current.contains(event.target as Node)) {
|
||||
if (!emojiContainerRef.current?.contains(event.target as Node)) {
|
||||
setShowEmojiPicker(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (showEmojiPicker) {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [showEmojiPicker])
|
||||
|
||||
return (
|
||||
|
|
@ -179,7 +171,7 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
|||
|
||||
{/* Media Preview */}
|
||||
<AnimatePresence>
|
||||
{mediaType === 'media' && mediaItems.length > 0 && (
|
||||
{hasMedia && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
|
|
@ -193,12 +185,10 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
|||
</h4>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearMedia()
|
||||
}}
|
||||
onClick={clearMedia}
|
||||
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"
|
||||
className={classNames(LINK_BUTTON_CLASS, 'text-red-600 hover:text-red-700')}
|
||||
title={translate(
|
||||
'::App.Platform.Intranet.SocialWall.CreatePost.RemoveAllMediaTitle',
|
||||
)}
|
||||
|
|
@ -220,10 +210,12 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
|||
<video
|
||||
src={item.urls?.[0]}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
muted
|
||||
preload="metadata"
|
||||
/>
|
||||
<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-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>
|
||||
|
|
@ -256,46 +248,6 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
|||
</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' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
|
|
@ -309,12 +261,10 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
|||
</h4>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
clearMedia()
|
||||
}}
|
||||
onClick={clearMedia}
|
||||
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"
|
||||
className={classNames(LINK_BUTTON_CLASS, 'text-red-600 hover:text-red-700')}
|
||||
title={translate('::App.Platform.Intranet.SocialWall.CreatePost.RemovePollTitle')}
|
||||
>
|
||||
{translate('::Cancel')}
|
||||
|
|
@ -331,6 +281,7 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
|||
/>
|
||||
<div className="space-y-2">
|
||||
{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">
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
{pollOptions.length > 2 && (
|
||||
{pollOptions.length > MIN_POLL_OPTIONS && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => removePollOption(index)}
|
||||
|
|
@ -354,13 +305,16 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
|||
</div>
|
||||
))}
|
||||
</div>
|
||||
{pollOptions.length < 6 && (
|
||||
{pollOptions.length < MAX_POLL_OPTIONS && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={addPollOption}
|
||||
variant="plain"
|
||||
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')}
|
||||
</Button>
|
||||
|
|
@ -382,23 +336,19 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
|||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (mediaType === 'media' && mediaItems.length > 0) {
|
||||
// Eğer zaten medya varsa, yöneticiyi aç
|
||||
setShowMediaManager(true)
|
||||
} else {
|
||||
// Başka bir tip seçiliyse temizle ve medya modunu aç
|
||||
// Farklı bir tip seçiliyse önce temizle, sonra medya modunu aç
|
||||
if (mediaType !== 'media') {
|
||||
clearMedia()
|
||||
setMediaType('media')
|
||||
setShowMediaManager(true)
|
||||
}
|
||||
setShowMediaManager(true)
|
||||
}}
|
||||
variant="plain"
|
||||
shape="circle"
|
||||
className={classNames(
|
||||
'relative !h-9 !w-9 !px-0 overflow-visible transition-colors',
|
||||
mediaType === 'media'
|
||||
? '!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',
|
||||
TOOLBAR_BUTTON_CLASS,
|
||||
'relative overflow-visible',
|
||||
mediaType === 'media' ? TOOLBAR_ACTIVE_CLASS : TOOLBAR_IDLE_CLASS,
|
||||
)}
|
||||
title={
|
||||
mediaType === 'media'
|
||||
|
|
@ -409,7 +359,7 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
|||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<FaImages className="h-5 w-5" />
|
||||
</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">
|
||||
{mediaItems.length}
|
||||
</span>
|
||||
|
|
@ -418,20 +368,19 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
|||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
// Başka bir tip seçiliyse temizle
|
||||
if (mediaType !== 'poll') {
|
||||
if (mediaType === 'poll') {
|
||||
clearMedia()
|
||||
return
|
||||
}
|
||||
setMediaType(mediaType === 'poll' ? null : 'poll')
|
||||
clearMedia()
|
||||
setMediaType('poll')
|
||||
}}
|
||||
variant="plain"
|
||||
shape="circle"
|
||||
icon={<FaChartBar className="h-5 w-5" />}
|
||||
className={classNames(
|
||||
'!h-9 !w-9 !px-0 transition-colors',
|
||||
mediaType === 'poll'
|
||||
? '!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',
|
||||
TOOLBAR_BUTTON_CLASS,
|
||||
mediaType === 'poll' ? TOOLBAR_ACTIVE_CLASS : TOOLBAR_IDLE_CLASS,
|
||||
)}
|
||||
title={
|
||||
mediaType === 'poll'
|
||||
|
|
@ -439,58 +388,39 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
|||
: translate('::App.Platform.Intranet.SocialWall.CreatePost.AddPollTitle')
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
|
||||
variant="plain"
|
||||
shape="circle"
|
||||
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"
|
||||
title={translate('::App.Platform.Intranet.SocialWall.CreatePost.AddEmojiTitle')}
|
||||
/>
|
||||
<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 && (
|
||||
<div
|
||||
ref={emojiPickerRef}
|
||||
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"
|
||||
>
|
||||
<React.Suspense fallback={<div className="h-[350px] w-[350px] max-w-[calc(100vw-32px)]" />}>
|
||||
<EmojiPicker
|
||||
searchDisabled
|
||||
theme={(theme.mode === 'dark' ? 'dark' : 'light') as Theme}
|
||||
height={350}
|
||||
onEmojiClick={handleEmojiClick}
|
||||
autoFocusSearch={false}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
)}
|
||||
<div ref={emojiContainerRef} className="relative">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setShowEmojiPicker((prev) => !prev)}
|
||||
variant="plain"
|
||||
shape="circle"
|
||||
icon={<FaSmile className="h-5 w-5" />}
|
||||
className={classNames(TOOLBAR_BUTTON_CLASS, TOOLBAR_IDLE_CLASS)}
|
||||
title={translate('::App.Platform.Intranet.SocialWall.CreatePost.AddEmojiTitle')}
|
||||
aria-expanded={showEmojiPicker}
|
||||
/>
|
||||
{showEmojiPicker && (
|
||||
<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">
|
||||
<React.Suspense
|
||||
fallback={
|
||||
<div className="h-[350px] w-[350px] max-w-[calc(100vw-32px)]" />
|
||||
}
|
||||
>
|
||||
<EmojiPicker
|
||||
searchDisabled
|
||||
theme={(theme.mode === 'dark' ? 'dark' : 'light') as Theme}
|
||||
height={350}
|
||||
onEmojiClick={handleEmojiClick}
|
||||
autoFocusSearch={false}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={!content.trim() && mediaItems.length === 0 && !mediaType}
|
||||
variant="solid"
|
||||
>
|
||||
<Button size="sm" type="submit" disabled={!canSubmit} variant="solid">
|
||||
{translate('::App.Platform.Intranet.SocialWall.CreatePost.Submit')}
|
||||
</Button>
|
||||
</motion.div>
|
||||
|
|
@ -508,13 +438,6 @@ const CreatePost: React.FC<CreatePostProps> = ({ onCreatePost }) => {
|
|||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Location Picker Modal */}
|
||||
<AnimatePresence>
|
||||
{showLocationPicker && (
|
||||
<LocationPicker onSelect={setLocation} onClose={() => setShowLocationPicker(false)} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -12,11 +12,32 @@ interface MediaManagerProps {
|
|||
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 { translate } = useLocalization()
|
||||
const [activeTab, setActiveTab] = useState<'upload' | 'url'>('upload')
|
||||
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') =>
|
||||
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',
|
||||
|
|
@ -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',
|
||||
)
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (!files) return
|
||||
|
||||
const fileArray = Array.from(files)
|
||||
const readers = fileArray.map(
|
||||
(file) =>
|
||||
new Promise<SocialMediaDto>((resolve) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
resolve({
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
type: file.type.startsWith('video/') ? 'video' : 'image',
|
||||
urls: [reader.result as string],
|
||||
})
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}),
|
||||
const typeButtonClassName = (kind: MediaKind) =>
|
||||
classNames(
|
||||
'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',
|
||||
)
|
||||
|
||||
Promise.all(readers).then((newMedia) => {
|
||||
onChange([...media, ...newMedia])
|
||||
})
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const input = e.target
|
||||
const files = Array.from(input.files ?? [])
|
||||
input.value = ''
|
||||
if (files.length === 0) return
|
||||
|
||||
e.target.value = ''
|
||||
const results = await Promise.all(
|
||||
files.map(async (file): Promise<SocialMediaDto | null> => {
|
||||
const kind: MediaKind = file.type.startsWith('video/') ? 'video' : 'image'
|
||||
if (lockedType && kind !== lockedType) return null
|
||||
|
||||
const dataUrl = await readFileAsDataUrl(file)
|
||||
if (!dataUrl) return null
|
||||
|
||||
return { id: crypto.randomUUID(), type: kind, urls: [dataUrl] }
|
||||
}),
|
||||
)
|
||||
|
||||
const added = results.filter((item): item is SocialMediaDto => item !== null)
|
||||
if (added.length === 0) return
|
||||
|
||||
// İ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 = () => {
|
||||
if (!urlInput.trim()) return
|
||||
const url = urlInput.trim()
|
||||
if (!url) return
|
||||
|
||||
const newMedia: SocialMediaDto = {
|
||||
id: Math.random().toString(36).substr(2, 9),
|
||||
type: mediaType,
|
||||
urls: [urlInput],
|
||||
}
|
||||
|
||||
onChange([...media, newMedia])
|
||||
onChange([...media, { id: crypto.randomUUID(), type: effectiveType, urls: [url] }])
|
||||
setUrlInput('')
|
||||
}
|
||||
|
||||
|
|
@ -84,6 +107,7 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
|
|||
{translate('::App.Platform.Intranet.SocialWall.MediaManager.AddMedia')}
|
||||
</h2>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
variant="plain"
|
||||
shape="circle"
|
||||
|
|
@ -95,6 +119,7 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
|
|||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700 px-2 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('upload')}
|
||||
variant="plain"
|
||||
shape="none"
|
||||
|
|
@ -106,6 +131,7 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
|
|||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('url')}
|
||||
variant="plain"
|
||||
shape="none"
|
||||
|
|
@ -119,57 +145,48 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
|
|||
{/* Content */}
|
||||
<div className="p-4 overflow-y-auto max-h-[calc(90vh-240px)]">
|
||||
{activeTab === 'upload' ? (
|
||||
<div>
|
||||
<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">
|
||||
<FaUpload className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
||||
<p className="text-gray-700 dark:text-gray-300 font-medium mb-1">
|
||||
{translate('::App.Platform.Intranet.SocialWall.MediaManager.ClickToSelectFile')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{translate(
|
||||
'::App.Platform.Intranet.SocialWall.MediaManager.ImageOrVideoFormats',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,video/*"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<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">
|
||||
<FaUpload className="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
||||
<p className="text-gray-700 dark:text-gray-300 font-medium mb-1">
|
||||
{translate('::App.Platform.Intranet.SocialWall.MediaManager.ClickToSelectFile')}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{translate('::App.Platform.Intranet.SocialWall.MediaManager.ImageOrVideoFormats')}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept={acceptedFiles}
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<div>
|
||||
<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')}
|
||||
</label>
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setMediaType('image')}
|
||||
variant="solid"
|
||||
className={classNames(
|
||||
'flex-1 !h-auto !rounded-lg !px-4 !py-2 font-medium transition-colors',
|
||||
mediaType === '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',
|
||||
)}
|
||||
disabled={lockedType === 'video'}
|
||||
variant="plain"
|
||||
shape="none"
|
||||
className={typeButtonClassName('image')}
|
||||
>
|
||||
{translate('::App.Platform.Intranet.SocialWall.MediaManager.Image')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setMediaType('video')}
|
||||
disabled={lockedType === 'image'}
|
||||
variant="plain"
|
||||
shape="none"
|
||||
className={classNames(
|
||||
'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',
|
||||
)}
|
||||
className={typeButtonClassName('video')}
|
||||
>
|
||||
{translate('::App.Platform.Intranet.SocialWall.MediaManager.Video')}
|
||||
</Button>
|
||||
|
|
@ -180,15 +197,21 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
|
|||
type="url"
|
||||
value={urlInput}
|
||||
onChange={(e) => setUrlInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && handleUrlAdd()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleUrlAdd()
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
mediaType === 'image'
|
||||
effectiveType === 'image'
|
||||
? translate('::App.Platform.Intranet.SocialWall.MediaManager.EnterImageUrl')
|
||||
: 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"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleUrlAdd}
|
||||
disabled={!urlInput.trim()}
|
||||
|
|
@ -217,19 +240,22 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
|
|||
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
|
||||
src={item.urls?.[0]}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
muted
|
||||
preload="metadata"
|
||||
/>
|
||||
<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-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>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => removeMedia(item.id)}
|
||||
variant="solid"
|
||||
shape="circle"
|
||||
|
|
@ -250,14 +276,11 @@ const MediaManager: React.FC<MediaManagerProps> = ({ media, onChange, onClose })
|
|||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
variant="plain"
|
||||
>
|
||||
<Button type="button" size="sm" onClick={onClose} variant="plain">
|
||||
{translate('::Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
disabled={media.length === 0}
|
||||
|
|
|
|||
|
|
@ -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 { motion, AnimatePresence } from 'framer-motion'
|
||||
import classNames from 'classnames'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import 'dayjs/locale/tr'
|
||||
import { FaHeart, FaRegHeart, FaRegCommentAlt, FaTrash, FaPaperPlane } from 'react-icons/fa'
|
||||
import DOMPurify from 'dompurify'
|
||||
import {
|
||||
FaHeart,
|
||||
FaRegHeart,
|
||||
FaRegCommentAlt,
|
||||
FaTrash,
|
||||
FaPaperPlane,
|
||||
FaExpand,
|
||||
} from 'react-icons/fa'
|
||||
import MediaLightbox from './MediaLightbox'
|
||||
import UserProfileCard from './UserProfileCard'
|
||||
import { SocialPostDto } from '@/proxy/intranet/models'
|
||||
|
|
@ -13,8 +20,13 @@ import { useStoreState } from '@/store/store'
|
|||
import { AVATAR_URL } from '@/constants/app.constant'
|
||||
import { Avatar, Button } from '@/components/ui'
|
||||
|
||||
// Aktif dil `useLocale` tarafından global olarak ayarlanır; burada sabitlenmez.
|
||||
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 {
|
||||
post: SocialPostDto
|
||||
|
|
@ -24,6 +36,12 @@ interface PostItemProps {
|
|||
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 { translate } = useLocalization()
|
||||
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 videoRef = useRef<HTMLVideoElement>(null)
|
||||
const { user } = useStoreState((state) => state.auth)
|
||||
|
||||
const postUser = post.user ?? user ?? {}
|
||||
const postComments = post.comments ?? []
|
||||
const postLikeCount = post.likeCount ?? 0
|
||||
|
|
@ -44,217 +63,217 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
|
|||
postUser.fullName || [postUser.name, postUser.surname].filter(Boolean).join(' ') || '-'
|
||||
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(() => {
|
||||
const video = videoRef.current
|
||||
if (!video) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
// Video ekranda görünür - oynat
|
||||
video.play().catch((err) => {
|
||||
console.log('Video autoplay failed:', err)
|
||||
})
|
||||
} else {
|
||||
// Video ekrandan çıktı - durdur
|
||||
video.pause()
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
threshold: 0.5, // Video %50 görünür olduğunda oynat
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
video.play().catch(() => {
|
||||
// Tarayıcı otomatik oynatmayı engelledi — sessizce yoksay
|
||||
})
|
||||
} else {
|
||||
video.pause()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 },
|
||||
)
|
||||
|
||||
observer.observe(video)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [post.media?.type])
|
||||
return () => observer.disconnect()
|
||||
}, [mediaType])
|
||||
|
||||
const handleSubmitComment = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (commentText.trim()) {
|
||||
onComment(post.id, commentText)
|
||||
setCommentText('')
|
||||
}
|
||||
const trimmed = commentText.trim()
|
||||
if (!trimmed) return
|
||||
onComment(post.id, trimmed)
|
||||
setCommentText('')
|
||||
}
|
||||
|
||||
const getImageLayout = (images: string[]) => {
|
||||
const count = images.length
|
||||
if (count === 1) return 'single'
|
||||
if (count === 2) return 'double'
|
||||
if (count === 3) return 'triple'
|
||||
return 'multiple'
|
||||
const renderImages = (urls: string[]) => {
|
||||
const { grid, cell } = imageGridClass(urls.length)
|
||||
const displayImages = showAllImages ? urls : urls.slice(0, MAX_VISIBLE_IMAGES)
|
||||
const hiddenCount = urls.length - MAX_VISIBLE_IMAGES
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classNames('mt-3 rounded-lg overflow-hidden', grid)}>
|
||||
{displayImages.map((url, index) => (
|
||||
<div
|
||||
key={`${index}-${url}`}
|
||||
className={classNames('relative', cell, {
|
||||
'col-span-2': urls.length === 3 && index === 0,
|
||||
})}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={`${postUserFullName} ${index + 1}`}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => {
|
||||
setLightboxIndex(index)
|
||||
setLightboxOpen(true)
|
||||
}}
|
||||
/>
|
||||
{hiddenCount > 0 && index === MAX_VISIBLE_IMAGES - 1 && !showAllImages && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black bg-opacity-60 flex items-center justify-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowAllImages(true)
|
||||
}}
|
||||
>
|
||||
<span className="text-white text-2xl font-bold">+{hiddenCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{lightboxOpen && (
|
||||
<MediaLightbox
|
||||
isOpen
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
media={{ type: 'image', urls }}
|
||||
startIndex={lightboxIndex}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const renderVideo = (url: string) => (
|
||||
<>
|
||||
<div className="mt-3 rounded-lg overflow-hidden relative group">
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={url}
|
||||
className="w-full max-h-96 object-cover"
|
||||
controls
|
||||
playsInline
|
||||
muted
|
||||
loop
|
||||
/>
|
||||
{/* `controls` ile çakışmaması için tam ekran ayrı bir düğmede */}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
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] }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
const renderPoll = () => {
|
||||
const media = post.media
|
||||
if (!media?.pollQuestion || !media.pollOptions) return null
|
||||
|
||||
const pollEndsAt = media.pollEndsAt ? new Date(media.pollEndsAt) : null
|
||||
const isExpired = pollEndsAt ? new Date() > pollEndsAt : false
|
||||
const hasVoted = !!media.pollUserVoteId
|
||||
const totalVotes = media.pollTotalVotes || 0
|
||||
const isLocked = hasVoted || isExpired
|
||||
|
||||
return (
|
||||
<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">{media.pollQuestion}</h4>
|
||||
<div className="space-y-2">
|
||||
{media.pollOptions.map((option) => {
|
||||
const percentage = totalVotes > 0 ? ((option.votes ?? 0) / totalVotes) * 100 : 0
|
||||
const isSelected = media.pollUserVoteId === option.id
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={option.id}
|
||||
onClick={() => option.id && !isLocked && onVote(post.id, option.id)}
|
||||
disabled={isLocked}
|
||||
variant="plain"
|
||||
shape="none"
|
||||
className={classNames(
|
||||
'w-full !h-auto !justify-start !rounded-lg !p-3 text-left relative overflow-hidden transition-all',
|
||||
{
|
||||
'!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':
|
||||
!isSelected && !isLocked,
|
||||
'bg-white dark:bg-gray-600 cursor-not-allowed': isLocked,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{hasVoted && (
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-blue-200 dark:bg-blue-800 transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
)}
|
||||
<div className="relative z-10 flex justify-between items-center">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{option.text}
|
||||
</span>
|
||||
{hasVoted && (
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
{percentage.toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{translate('::App.Platform.Intranet.SocialWall.PostItem.VoteCount', {
|
||||
count: totalVotes,
|
||||
})}
|
||||
{isExpired && (
|
||||
<> • {translate('::App.Platform.Intranet.SocialWall.PostItem.PollEnded')}</>
|
||||
)}
|
||||
{!isExpired && pollEndsAt && (
|
||||
<>
|
||||
{' • '}
|
||||
{translate('::App.Platform.Intranet.SocialWall.PostItem.PollEndsIn', {
|
||||
time: dayjs(pollEndsAt).fromNow(true),
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderMedia = () => {
|
||||
if (!post.media) return null
|
||||
const media = post.media
|
||||
if (!media) return null
|
||||
|
||||
switch (post.media.type) {
|
||||
const urls = (media.urls ?? []).filter(Boolean)
|
||||
|
||||
switch (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 (
|
||||
<>
|
||||
<div
|
||||
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) => (
|
||||
<div
|
||||
key={index}
|
||||
className={classNames('relative', {
|
||||
'col-span-2': layout === 'triple' && index === 0,
|
||||
'aspect-video': layout === 'single',
|
||||
'aspect-square': layout !== 'single',
|
||||
})}
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={`Post image ${index + 1}`}
|
||||
className="w-full h-full object-cover cursor-pointer hover:opacity-90 transition-opacity"
|
||||
onClick={() => {
|
||||
setLightboxIndex(index)
|
||||
setLightboxOpen(true)
|
||||
}}
|
||||
/>
|
||||
{hasMore && index === 3 && !showAllImages && post.media?.urls && (
|
||||
<div
|
||||
className="absolute inset-0 bg-black bg-opacity-60 flex items-center justify-center cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowAllImages(true)
|
||||
}}
|
||||
>
|
||||
<span className="text-white text-2xl font-bold">
|
||||
+{post.media.urls.length - 4}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<MediaLightbox
|
||||
isOpen={lightboxOpen}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
media={{ type: 'image', urls: post.media.urls }}
|
||||
startIndex={lightboxIndex}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
break
|
||||
|
||||
return urls.length > 0 ? renderImages(urls) : null
|
||||
case 'video':
|
||||
if (post.media.urls && post.media.urls.length > 0) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="mt-3 rounded-lg overflow-hidden cursor-pointer relative group"
|
||||
onClick={() => setLightboxOpen(true)}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={post.media.urls[0]}
|
||||
className="w-full max-h-96 object-cover"
|
||||
controls
|
||||
playsInline
|
||||
muted
|
||||
loop
|
||||
/>
|
||||
</div>
|
||||
<MediaLightbox
|
||||
isOpen={lightboxOpen}
|
||||
onClose={() => setLightboxOpen(false)}
|
||||
media={{ type: 'video', urls: [post.media.urls[0]] }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
break
|
||||
|
||||
return urls.length > 0 ? renderVideo(urls[0]) : null
|
||||
case 'poll':
|
||||
if (post.media.pollQuestion && post.media.pollOptions) {
|
||||
const pollEndsAt = post.media.pollEndsAt ? new Date(post.media.pollEndsAt) : null
|
||||
const isExpired = pollEndsAt ? new Date() > pollEndsAt : false
|
||||
const hasVoted = !!post.media.pollUserVoteId
|
||||
const totalVotes = post.media.pollTotalVotes || 0
|
||||
const pollUserVoteId = post.media.pollUserVoteId
|
||||
|
||||
return (
|
||||
<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">
|
||||
{post.media.pollQuestion}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{post.media.pollOptions.map((option) => {
|
||||
const percentage = totalVotes > 0 ? ((option.votes ?? 0) / totalVotes) * 100 : 0
|
||||
const isSelected = pollUserVoteId === option.id
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={option.id}
|
||||
onClick={() =>
|
||||
option.id && !hasVoted && !isExpired && onVote(post.id, option.id)
|
||||
}
|
||||
disabled={hasVoted || isExpired}
|
||||
variant="plain"
|
||||
shape="none"
|
||||
className={classNames(
|
||||
'w-full !h-auto !justify-start !rounded-lg !p-3 text-left relative overflow-hidden transition-all',
|
||||
{
|
||||
'!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':
|
||||
!isSelected && !hasVoted && !isExpired,
|
||||
'bg-white dark:bg-gray-600 cursor-not-allowed': hasVoted || isExpired,
|
||||
},
|
||||
)}
|
||||
>
|
||||
{hasVoted && (
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-blue-200 dark:bg-blue-800 transition-all"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
)}
|
||||
<div className="relative z-10 flex justify-between items-center">
|
||||
<span className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{option.text}
|
||||
</span>
|
||||
{hasVoted && (
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-gray-200">
|
||||
{percentage.toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{totalVotes} oy •{' '}
|
||||
{isExpired
|
||||
? 'Sona erdi'
|
||||
: pollEndsAt
|
||||
? dayjs(pollEndsAt).fromNow() + ' bitiyor'
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
break
|
||||
return renderPoll()
|
||||
default:
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -313,7 +332,7 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
|
|||
<div className="mb-3">
|
||||
<div
|
||||
className="whitespace-pre-wrap text-gray-800 dark:text-gray-200"
|
||||
dangerouslySetInnerHTML={{ __html: post.content || '' }}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
|
||||
/>
|
||||
{renderMedia()}
|
||||
</div>
|
||||
|
|
@ -331,19 +350,18 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
|
|||
<FaRegHeart className="h-5 w-5" />
|
||||
)
|
||||
}
|
||||
className={classNames(
|
||||
'!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',
|
||||
)}
|
||||
className={ACTION_BUTTON_CLASS}
|
||||
>
|
||||
<span className="text-sm font-medium">{postLikeCount}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowComments(!showComments)}
|
||||
onClick={() => setShowComments((prev) => !prev)}
|
||||
variant="plain"
|
||||
shape="none"
|
||||
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>
|
||||
</Button>
|
||||
|
|
@ -398,11 +416,11 @@ const PostItem: React.FC<PostItemProps> = ({ post, onLike, onComment, onDelete,
|
|||
{hoveredCommentAuthor === comment.id && (
|
||||
<UserProfileCard
|
||||
user={{
|
||||
id: comment.user?.id || '',
|
||||
name: comment.user?.fullName || '',
|
||||
title: comment.user?.jobPositions?.[0]?.name || '',
|
||||
phoneNumber: comment.user.phoneNumber,
|
||||
tenantId: comment.user?.tenantId || '',
|
||||
id: comment.user?.id ?? '',
|
||||
name: comment.user?.fullName ?? '',
|
||||
title: comment.user?.jobPositions?.[0]?.name ?? '',
|
||||
phoneNumber: comment.user?.phoneNumber,
|
||||
tenantId: comment.user?.tenantId ?? '',
|
||||
}}
|
||||
position="bottom"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,46 +1,90 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useLocalization } from '@/utils/hooks/useLocalization'
|
||||
import { AnimatePresence } from 'framer-motion'
|
||||
import classNames from 'classnames'
|
||||
import PostItem from './PostItem'
|
||||
import CreatePost from './CreatePost'
|
||||
import { SocialMediaDto, SocialPostDto } from '@/proxy/intranet/models'
|
||||
import { intranetService } from '@/services/intranet.service'
|
||||
import type { CreateSocialPostInput } from '@/services/intranet.service'
|
||||
import Button from '@/components/ui/Button'
|
||||
import type { DashboardWidgetDefinition } from '../dashboardWidget'
|
||||
|
||||
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 { translate } = useLocalization()
|
||||
const [posts, setPosts] = useState<SocialPostDto[]>([])
|
||||
const [skipCount, setSkipCount] = useState(0)
|
||||
const [filter, setFilter] = useState<PostFilter>('all')
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const sentinelRef = useRef<HTMLDivElement>(null)
|
||||
const skipCountRef = useRef(0)
|
||||
const loadingRef = useRef(false)
|
||||
const hasMoreRef = useRef(true)
|
||||
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingMore || !hasMore) return
|
||||
if (loadingRef.current || !hasMoreRef.current) return
|
||||
loadingRef.current = true
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const res = await intranetService.getIntranetSocialPosts(skipCount, PAGE_SIZE)
|
||||
const newPosts = res.data ?? []
|
||||
const res = await intranetService.getIntranetSocialPosts(skipCountRef.current, PAGE_SIZE)
|
||||
const fetched = res.data ?? []
|
||||
skipCountRef.current += fetched.length
|
||||
hasMoreRef.current = fetched.length === PAGE_SIZE
|
||||
setHasMore(hasMoreRef.current)
|
||||
setPosts((prev) => {
|
||||
const existingIds = new Set(prev.map((p) => p.id))
|
||||
const unique = newPosts.filter((p) => !existingIds.has(p.id))
|
||||
return [...prev, ...unique]
|
||||
return [...prev, ...fetched.filter((p) => !existingIds.has(p.id))]
|
||||
})
|
||||
setSkipCount((s) => s + newPosts.length)
|
||||
setHasMore(newPosts.length === PAGE_SIZE)
|
||||
} catch {
|
||||
// error handled by apiService
|
||||
// hata apiService tarafından ele alınıyor
|
||||
} finally {
|
||||
loadingRef.current = 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(() => {
|
||||
const sentinel = sentinelRef.current
|
||||
if (!sentinel) return
|
||||
if (!sentinel || !hasMore) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) loadMore()
|
||||
|
|
@ -49,164 +93,108 @@ const SocialWall: React.FC = () => {
|
|||
)
|
||||
observer.observe(sentinel)
|
||||
return () => observer.disconnect()
|
||||
}, [loadMore])
|
||||
const [filter, setFilter] = useState<'all' | 'mine'>('all')
|
||||
const { translate } = useLocalization()
|
||||
}, [loadMore, hasMore, posts.length])
|
||||
|
||||
const updatePosts = (updated: SocialPostDto[]) => {
|
||||
setPosts(updated)
|
||||
}
|
||||
|
||||
const handleCreatePost = async (postData: {
|
||||
content: string
|
||||
location?: string
|
||||
media?: {
|
||||
type: 'mixed' | 'poll'
|
||||
mediaItems?: SocialMediaDto[]
|
||||
poll?: {
|
||||
question: string
|
||||
options: Array<{ text: string }>
|
||||
}
|
||||
}
|
||||
const handleCreatePost: React.ComponentProps<typeof CreatePost>['onCreatePost'] = async ({
|
||||
content,
|
||||
media,
|
||||
}) => {
|
||||
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 {
|
||||
const response = await intranetService.createSocialPost({
|
||||
content: postData.content,
|
||||
locationJson: postData.location,
|
||||
media: mediaInput,
|
||||
content,
|
||||
media: buildMediaInput(media),
|
||||
})
|
||||
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 {
|
||||
// error handled by apiService
|
||||
// hata apiService tarafından ele alınıyor
|
||||
}
|
||||
}
|
||||
|
||||
const handleLike = async (postId: string) => {
|
||||
try {
|
||||
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 {
|
||||
// error handled by apiService
|
||||
// hata apiService tarafından ele alınıyor
|
||||
}
|
||||
}
|
||||
|
||||
const handleComment = async (postId: string, content: string) => {
|
||||
try {
|
||||
const response = await intranetService.commentSocialPost(postId, content)
|
||||
updatePosts(
|
||||
posts.map((p) =>
|
||||
setPosts((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === postId ? { ...p, comments: [...(p.comments || []), response.data] } : p,
|
||||
),
|
||||
)
|
||||
} catch {
|
||||
// error handled by apiService
|
||||
// hata apiService tarafından ele alınıyor
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (postId: string) => {
|
||||
if (window.confirm(translate('::App.Platform.Intranet.SocialWall.DeleteConfirm'))) {
|
||||
try {
|
||||
await intranetService.deleteSocialPost(postId)
|
||||
updatePosts(posts.filter((p) => p.id !== postId))
|
||||
} catch {
|
||||
// error handled by apiService
|
||||
}
|
||||
if (!window.confirm(translate('::App.Platform.Intranet.SocialWall.DeleteConfirm'))) return
|
||||
try {
|
||||
await intranetService.deleteSocialPost(postId)
|
||||
skipCountRef.current = Math.max(0, skipCountRef.current - 1)
|
||||
setPosts((prev) => prev.filter((p) => p.id !== postId))
|
||||
} catch {
|
||||
// hata apiService tarafından ele alınıyor
|
||||
}
|
||||
}
|
||||
|
||||
const handleVote = async (postId: string, optionId: string) => {
|
||||
try {
|
||||
await intranetService.voteSocialPoll(postId, optionId)
|
||||
updatePosts(
|
||||
posts.map((p) => {
|
||||
if (p.id === postId && p.media?.type === 'poll' && p.media.pollOptions) {
|
||||
if (p.media.pollUserVoteId) return p
|
||||
return {
|
||||
...p,
|
||||
media: {
|
||||
...p.media,
|
||||
pollOptions: p.media.pollOptions.map((opt) =>
|
||||
opt.id === optionId ? { ...opt, votes: opt.votes + 1 } : opt,
|
||||
),
|
||||
pollTotalVotes: (p.media.pollTotalVotes || 0) + 1,
|
||||
pollUserVoteId: optionId,
|
||||
},
|
||||
}
|
||||
setPosts((prev) =>
|
||||
prev.map((p) => {
|
||||
if (p.id !== postId || p.media?.type !== 'poll' || !p.media.pollOptions) return p
|
||||
if (p.media.pollUserVoteId) return p
|
||||
return {
|
||||
...p,
|
||||
media: {
|
||||
...p.media,
|
||||
pollOptions: p.media.pollOptions.map((opt) =>
|
||||
opt.id === optionId ? { ...opt, votes: opt.votes + 1 } : opt,
|
||||
),
|
||||
pollTotalVotes: (p.media.pollTotalVotes || 0) + 1,
|
||||
pollUserVoteId: optionId,
|
||||
},
|
||||
}
|
||||
return p
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
// error handled by apiService
|
||||
// hata apiService tarafından ele alınıyor
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mx-auto px-4">
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex gap-4 mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<Button
|
||||
onClick={() => setFilter('all')}
|
||||
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>
|
||||
{renderTab('all', '::App.Platform.Intranet.SocialWall.AllPosts')}
|
||||
{renderTab('mine', '::App.Platform.Intranet.SocialWall.MyPosts')}
|
||||
</div>
|
||||
|
||||
{/* Create Post */}
|
||||
|
|
@ -214,44 +202,39 @@ const SocialWall: React.FC = () => {
|
|||
|
||||
{/* Posts Feed */}
|
||||
<AnimatePresence>
|
||||
{filteredPosts.length > 0 ? (
|
||||
filteredPosts.map((post) => (
|
||||
<PostItem
|
||||
key={post.id}
|
||||
post={post}
|
||||
onLike={handleLike}
|
||||
onComment={handleComment}
|
||||
onDelete={handleDelete}
|
||||
onVote={handleVote}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg">
|
||||
{filter === 'mine'
|
||||
? translate('::App.Platform.Intranet.SocialWall.NoMyPosts')
|
||||
: translate('::App.Platform.Intranet.SocialWall.NoPosts')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{filteredPosts.map((post) => (
|
||||
<PostItem
|
||||
key={post.id}
|
||||
post={post}
|
||||
onLike={handleLike}
|
||||
onComment={handleComment}
|
||||
onDelete={handleDelete}
|
||||
onVote={handleVote}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{filteredPosts.length === 0 && !loadingMore && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400 text-lg">
|
||||
{filter === 'mine'
|
||||
? translate('::App.Platform.Intranet.SocialWall.NoMyPosts')
|
||||
: translate('::App.Platform.Intranet.SocialWall.NoPosts')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Infinite scroll sentinel */}
|
||||
{filter === 'all' && (
|
||||
<>
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
{loadingMore && (
|
||||
<div className="flex justify-center py-6">
|
||||
<div className="w-8 h-8 border-4 border-gray-200 border-t-blue-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && posts.length > 0 && (
|
||||
<p className="text-center text-sm text-gray-400 dark:text-gray-600 py-6">
|
||||
{translate('::App.Platform.Intranet.SocialWall.AllPostsLoaded') ||
|
||||
'Tüm gönderiler yüklendi'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
<div ref={sentinelRef} className="h-1" />
|
||||
{loadingMore && (
|
||||
<div className="flex justify-center py-6">
|
||||
<div className="w-8 h-8 border-4 border-gray-200 border-t-blue-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{!hasMore && posts.length > 0 && (
|
||||
<p className="text-center text-sm text-gray-400 dark:text-gray-600 py-6">
|
||||
{translate('::App.Platform.Intranet.SocialWall.AllPostsLoaded')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -17,11 +17,10 @@ const Surveys: React.FC<SurveysProps> = ({ surveys, onTakeSurvey }) => {
|
|||
const currentLocale = useLocale()
|
||||
const { translate } = useLocalization()
|
||||
const [hoveredSurveyId, setHoveredSurveyId] = useState<string | null>(null)
|
||||
const surveyList = surveys ?? []
|
||||
|
||||
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">
|
||||
{/* Header with gradient */}
|
||||
|
||||
<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">
|
||||
<FaClipboardCheck className="w-5 h-5" />
|
||||
|
|
@ -30,7 +29,7 @@ const Surveys: React.FC<SurveysProps> = ({ surveys, onTakeSurvey }) => {
|
|||
</div>
|
||||
|
||||
<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 urgency = daysLeft <= 3 ? 'urgent' : daysLeft <= 7 ? 'warning' : 'normal'
|
||||
const isCompleted = !!survey.myResponse
|
||||
|
|
@ -114,7 +113,7 @@ const Surveys: React.FC<SurveysProps> = ({ surveys, onTakeSurvey }) => {
|
|||
</div>
|
||||
|
||||
{/* 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="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" />
|
||||
|
|
@ -143,35 +142,6 @@ const Surveys: React.FC<SurveysProps> = ({ surveys, onTakeSurvey }) => {
|
|||
</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>
|
||||
|
||||
{/* 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="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" />
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ const TodayBirthdays: React.FC<{ employees: UserInfoViewModel[] }> = ({ employee
|
|||
<div className="p-2 space-y-3">
|
||||
{employees.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
{employees.map((birthday, index) => (
|
||||
<div key={index} className="flex items-center gap-2 p-2">
|
||||
{employees.map((birthday) => (
|
||||
<div key={birthday.id} className="flex items-center gap-2 p-2">
|
||||
<Avatar
|
||||
size={48}
|
||||
shape="circle"
|
||||
|
|
|
|||
1
ui/src/vite-env.d.ts
vendored
1
ui/src/vite-env.d.ts
vendored
|
|
@ -6,7 +6,6 @@ interface ImportMetaEnv {
|
|||
readonly VITE_CDN_URL: string
|
||||
readonly VITE_REACT_APP_VERSION: string
|
||||
readonly VITE_AI_URL: string
|
||||
readonly VITE_GOOGLE_MAPS_API_KEY: string
|
||||
readonly VITE_USE_POLLING?: string
|
||||
/** Dev sunucusunda service worker'ı açar (varsayılan kapalı). */
|
||||
readonly VITE_PWA_DEV?: string
|
||||
|
|
|
|||
Loading…
Reference in a new issue